@things-factory/ai-assistant 10.1.28 → 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 (96) hide show
  1. package/client/components/assistant-chat.ts +51 -7
  2. package/client/components/assistant-mode.test.ts +20 -1
  3. package/client/components/assistant-mode.ts +20 -0
  4. package/client/components/panel-aria.test.ts +20 -0
  5. package/client/utils/assistant-session-controller.ts +14 -1
  6. package/client/utils/assistant-session-transport.ts +2 -2
  7. package/dist-client/components/assistant-chat.d.ts +12 -0
  8. package/dist-client/components/assistant-chat.js +48 -6
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-mode.d.ts +22 -0
  11. package/dist-client/components/assistant-mode.js +16 -0
  12. package/dist-client/components/assistant-mode.js.map +1 -1
  13. package/dist-client/components/assistant-mode.test.js +17 -1
  14. package/dist-client/components/assistant-mode.test.js.map +1 -1
  15. package/dist-client/components/panel-aria.test.js +18 -0
  16. package/dist-client/components/panel-aria.test.js.map +1 -1
  17. package/dist-client/tsconfig.tsbuildinfo +1 -1
  18. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  19. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  20. package/dist-client/utils/assistant-session-transport.js +2 -2
  21. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  22. package/dist-server/door-declaration-report.d.ts +14 -0
  23. package/dist-server/door-declaration-report.js +54 -0
  24. package/dist-server/door-declaration-report.js.map +1 -0
  25. package/dist-server/service/assistant-chat-resolver.js +19 -0
  26. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  27. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +8 -0
  28. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +8 -0
  29. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  30. package/dist-server/service/assistant-mode/resolve.d.ts +10 -0
  31. package/dist-server/service/assistant-mode/resolve.js +5 -0
  32. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  33. package/dist-server/service/assistant-mode/types.d.ts +21 -0
  34. package/dist-server/service/assistant-mode/types.js.map +1 -1
  35. package/dist-server/service/chat-message/carried-turns.js +14 -2
  36. package/dist-server/service/chat-message/carried-turns.js.map +1 -1
  37. package/dist-server/service/chat-message/door-access.d.ts +19 -7
  38. package/dist-server/service/chat-message/door-access.js +19 -11
  39. package/dist-server/service/chat-message/door-access.js.map +1 -1
  40. package/dist-server/service/chat-message/reader-gate.js +6 -4
  41. package/dist-server/service/chat-message/reader-gate.js.map +1 -1
  42. package/dist-server/service/chat-session/chat-session.d.ts +12 -0
  43. package/dist-server/service/chat-session/chat-session.js +12 -11
  44. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  45. package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
  46. package/dist-server/service/chat-session/session-anchor.js +12 -4
  47. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  48. package/dist-server/service/chat-session/session-inbox.d.ts +2 -0
  49. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  50. package/dist-server/service/chat-session/session-kind.d.ts +14 -0
  51. package/dist-server/service/chat-session/session-kind.js +30 -0
  52. package/dist-server/service/chat-session/session-kind.js.map +1 -0
  53. package/dist-server/service/chat-session/session-visibility.js +8 -1
  54. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  55. package/dist-server/service/chat-session-resolver.d.ts +2 -0
  56. package/dist-server/service/chat-session-resolver.js +4 -0
  57. package/dist-server/service/chat-session-resolver.js.map +1 -1
  58. package/dist-server/service/index.d.ts +1 -0
  59. package/dist-server/service/index.js +2 -0
  60. package/dist-server/service/index.js.map +1 -1
  61. package/dist-server/service/testing/mode-conformance.d.ts +10 -0
  62. package/dist-server/service/testing/mode-conformance.js +13 -0
  63. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  64. package/dist-server/tool-registry-boot-report.js +7 -0
  65. package/dist-server/tool-registry-boot-report.js.map +1 -1
  66. package/dist-server/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +5 -5
  68. package/server/door-declaration-report.test.ts +53 -0
  69. package/server/door-declaration-report.ts +61 -0
  70. package/server/service/assistant-chat-resolver.ts +21 -0
  71. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  72. package/server/service/assistant-mode/resolve.test.ts +42 -0
  73. package/server/service/assistant-mode/resolve.ts +9 -0
  74. package/server/service/assistant-mode/types.ts +18 -0
  75. package/server/service/chat-message/carried-turns.test.ts +13 -2
  76. package/server/service/chat-message/carried-turns.ts +16 -3
  77. package/server/service/chat-message/door-access.test.ts +72 -0
  78. package/server/service/chat-message/door-access.ts +43 -23
  79. package/server/service/chat-message/fold-history-window.test.ts +184 -0
  80. package/server/service/chat-message/reader-gate.ts +6 -4
  81. package/server/service/chat-session/chat-session.ts +15 -0
  82. package/server/service/chat-session/session-anchor.ts +12 -3
  83. package/server/service/chat-session/session-inbox.ts +2 -0
  84. package/server/service/chat-session/session-kind.test.ts +24 -0
  85. package/server/service/chat-session/session-kind.ts +32 -0
  86. package/server/service/chat-session/session-visibility.test.ts +16 -0
  87. package/server/service/chat-session/session-visibility.ts +9 -1
  88. package/server/service/chat-session-resolver.ts +4 -0
  89. package/server/service/index.ts +2 -0
  90. package/server/service/testing/mode-conformance.ts +15 -0
  91. package/server/tool-registry-boot-report.ts +6 -0
  92. package/translations/en.json +3 -2
  93. package/translations/ja.json +3 -2
  94. package/translations/ko.json +3 -2
  95. package/translations/ms.json +3 -2
  96. package/translations/zh.json +3 -2
@@ -71,6 +71,27 @@ export interface AssistantModeSpec {
71
71
  * "describe the figure you want" while the person was there to change the figure already open.
72
72
  */
73
73
  placeholderKey?: string;
74
+ /**
75
+ * 빈 대화가 무엇을 말하는가 — 제목과 한 줄 (ADR-0061 결정 7).
76
+ *
77
+ * 자리표와 `/` 는 대화를 따라가는데 이 블록만 host 속성에 남아 있었다. 그래서 머리에는 「이 도형
78
+ * 고치기」가 서 있고 본문은 「만들 Figure 를 설명하세요」라고 적혀 있었다(2026-09-18, Figure 레인
79
+ * :3300). 한 대화가 화면에 무엇을 말하는가는 한 자리에서 나와야 한다.
80
+ */
81
+ introKeys?: {
82
+ title: string;
83
+ subtitle: string;
84
+ };
85
+ /**
86
+ * 빈 대화의 예시 — 「이 대화에서 할 수 있는 말」.
87
+ *
88
+ * 예시는 그 도구로 **실제로 할 수 있는 말**이어야 한다. 할 수 없는 것을 예시로 두면 빈 화면이 거짓을
89
+ * 가르친다. 대화마다 열리는 도구가 다르므로 예시도 대화를 따라간다.
90
+ */
91
+ exampleKeys?: Array<{
92
+ labelKey: string;
93
+ itemKeys: string[];
94
+ }>;
74
95
  /** i18n key for the line under an empty conversation. */
75
96
  footerNoticeKey?: string;
76
97
  /** i18n key for the line shown once the host reports a proposal staged. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/assistant-mode/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * What a conversation is, declared by the app server that owns the screen (ADR-0061 decision 7).\n *\n * ADR-0058 put these five in one value so they could not fall out of step, but the value lived in\n * the browser and travelled on every request: the persona, the tool categories, the model and the\n * limits were the caller's to choose. A caller choosing its own tool categories is a caller\n * choosing its own authority, so the declaration moved here and the client sends only which one it\n * is talking in (`kind`) and what about (`target`).\n */\n\n/** A `/` sentence this mode offers, and what it needs to be answerable. */\nexport interface AssistantModeSlash {\n /** Short name shown in the list. */\n name: string\n /** i18n key for the one-line description. The server translates it into the request's locale. */\n descriptionKey: string\n /** i18n key for the sentence that lands in the input. */\n templateKey: string\n /**\n * The tools this sentence needs. A mode must not offer work it cannot do (ADR-0058 decision 3),\n * and a name outside `toolCategories` is reported at boot rather than when a person presses it.\n */\n tools?: string[]\n}\n\nexport interface AssistantModeSpec {\n /** `<app>.<surface>` — `twin.space`, `plant.station`, `figure.modeller`. */\n kind: string\n\n /**\n * Which kinds of subject this conversation stands for — `['board', 'space']` (ADR-0072 A17 ①).\n *\n * A screen says what it is showing (`context.subject`), and these say which conversations that\n * subject can carry. It is a list of types and not a predicate on purpose: a list can be counted\n * at boot and pinned by a test, while a function can only be run.\n *\n * On the twin modeller nothing could be asked about operations, because an open board pinned the\n * authoring conversation and that conversation opens one category. A conversation that also\n * stands for `board` is how the same screen carries both.\n */\n subjects?: string[]\n\n /**\n * The conversation a screen opens when the person has not chosen — one per subject type\n * (ADR-0072 A17 ②).\n *\n * Registration order must not decide this: whichever module booted last would silently become\n * the default. Exactly one mode declares each subject type, and the boot report says so when a\n * type has none or more than one.\n */\n defaultFor?: string[]\n\n /**\n * i18n key for what this conversation is called where a person chooses between them —\n * \"이 그림 고치기\", \"이 현장 지금 어때\". A mode a screen may offer needs one.\n */\n labelKey?: string\n /** Who the assistant is here, and what it must not claim to do. The server's, not the caller's. */\n systemPrompt: string\n /** Which tool categories this conversation may use. Absent or empty exposes none. */\n toolCategories?: string[]\n /** Make the model read before it answers — for surfaces whose subject is live state. */\n requireToolOnFirstTurn?: boolean\n /** Model and limits are policy, not a request field. */\n model?: string\n temperature?: number\n maxTokens?: number\n /** What `/` offers. Declared as keys; the server translates. */\n slashTemplates?: AssistantModeSlash[]\n /** What `@` offers. */\n catalogEntries?: any[]\n /**\n * i18n key for the words in the empty input.\n *\n * The screen says what this conversation is for, and that differs by mode: figure's modeller had\n * \"describe the figure you want\" while the person was there to change the figure already open.\n */\n placeholderKey?: string\n /** i18n key for the line under an empty conversation. */\n footerNoticeKey?: string\n /** i18n key for the line shown once the host reports a proposal staged. */\n stagedNoticeKey?: string\n /** Follow-up chips for a proposal that arrived without its own. */\n proposalChoices?: any[]\n}\n\n/** A declaration that registered but is not what the contract asks for. Code + params, not prose. */\nexport interface AssistantModeWarning {\n /**\n * `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.\n * `assistant-mode-subject-without-default` — a subject type no conversation claims as its default.\n * `assistant-mode-subject-default-twice` — two conversations claim the same subject type.\n * `assistant-mode-offered-without-label` — a conversation a screen may offer with nothing to call it.\n */\n code:\n | 'assistant-mode-slash-outside-tools'\n | 'assistant-mode-subject-without-default'\n | 'assistant-mode-subject-default-twice'\n | 'assistant-mode-offered-without-label'\n params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[] }\n /** For a person reading the boot log; callers act on the code and params. */\n message: string\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/assistant-mode/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * What a conversation is, declared by the app server that owns the screen (ADR-0061 decision 7).\n *\n * ADR-0058 put these five in one value so they could not fall out of step, but the value lived in\n * the browser and travelled on every request: the persona, the tool categories, the model and the\n * limits were the caller's to choose. A caller choosing its own tool categories is a caller\n * choosing its own authority, so the declaration moved here and the client sends only which one it\n * is talking in (`kind`) and what about (`target`).\n */\n\n/** A `/` sentence this mode offers, and what it needs to be answerable. */\nexport interface AssistantModeSlash {\n /** Short name shown in the list. */\n name: string\n /** i18n key for the one-line description. The server translates it into the request's locale. */\n descriptionKey: string\n /** i18n key for the sentence that lands in the input. */\n templateKey: string\n /**\n * The tools this sentence needs. A mode must not offer work it cannot do (ADR-0058 decision 3),\n * and a name outside `toolCategories` is reported at boot rather than when a person presses it.\n */\n tools?: string[]\n}\n\nexport interface AssistantModeSpec {\n /** `<app>.<surface>` — `twin.space`, `plant.station`, `figure.modeller`. */\n kind: string\n\n /**\n * Which kinds of subject this conversation stands for — `['board', 'space']` (ADR-0072 A17 ①).\n *\n * A screen says what it is showing (`context.subject`), and these say which conversations that\n * subject can carry. It is a list of types and not a predicate on purpose: a list can be counted\n * at boot and pinned by a test, while a function can only be run.\n *\n * On the twin modeller nothing could be asked about operations, because an open board pinned the\n * authoring conversation and that conversation opens one category. A conversation that also\n * stands for `board` is how the same screen carries both.\n */\n subjects?: string[]\n\n /**\n * The conversation a screen opens when the person has not chosen — one per subject type\n * (ADR-0072 A17 ②).\n *\n * Registration order must not decide this: whichever module booted last would silently become\n * the default. Exactly one mode declares each subject type, and the boot report says so when a\n * type has none or more than one.\n */\n defaultFor?: string[]\n\n /**\n * i18n key for what this conversation is called where a person chooses between them —\n * \"이 그림 고치기\", \"이 현장 지금 어때\". A mode a screen may offer needs one.\n */\n labelKey?: string\n /** Who the assistant is here, and what it must not claim to do. The server's, not the caller's. */\n systemPrompt: string\n /** Which tool categories this conversation may use. Absent or empty exposes none. */\n toolCategories?: string[]\n /** Make the model read before it answers — for surfaces whose subject is live state. */\n requireToolOnFirstTurn?: boolean\n /** Model and limits are policy, not a request field. */\n model?: string\n temperature?: number\n maxTokens?: number\n /** What `/` offers. Declared as keys; the server translates. */\n slashTemplates?: AssistantModeSlash[]\n /** What `@` offers. */\n catalogEntries?: any[]\n /**\n * i18n key for the words in the empty input.\n *\n * The screen says what this conversation is for, and that differs by mode: figure's modeller had\n * \"describe the figure you want\" while the person was there to change the figure already open.\n */\n placeholderKey?: string\n\n /**\n * 빈 대화가 무엇을 말하는가 — 제목과 한 줄 (ADR-0061 결정 7).\n *\n * 자리표와 `/` 는 대화를 따라가는데 이 블록만 host 속성에 남아 있었다. 그래서 머리에는 「이 도형\n * 고치기」가 서 있고 본문은 「만들 Figure 를 설명하세요」라고 적혀 있었다(2026-09-18, Figure 레인\n * :3300). 한 대화가 화면에 무엇을 말하는가는 한 자리에서 나와야 한다.\n */\n introKeys?: { title: string; subtitle: string }\n\n /**\n * 빈 대화의 예시 — 「이 대화에서 할 수 있는 말」.\n *\n * 예시는 그 도구로 **실제로 할 수 있는 말**이어야 한다. 할 수 없는 것을 예시로 두면 빈 화면이 거짓을\n * 가르친다. 대화마다 열리는 도구가 다르므로 예시도 대화를 따라간다.\n */\n exampleKeys?: Array<{ labelKey: string; itemKeys: string[] }>\n\n /** i18n key for the line under an empty conversation. */\n footerNoticeKey?: string\n /** i18n key for the line shown once the host reports a proposal staged. */\n stagedNoticeKey?: string\n /** Follow-up chips for a proposal that arrived without its own. */\n proposalChoices?: any[]\n}\n\n/** A declaration that registered but is not what the contract asks for. Code + params, not prose. */\nexport interface AssistantModeWarning {\n /**\n * `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.\n * `assistant-mode-subject-without-default` — a subject type no conversation claims as its default.\n * `assistant-mode-subject-default-twice` — two conversations claim the same subject type.\n * `assistant-mode-offered-without-label` — a conversation a screen may offer with nothing to call it.\n */\n code:\n | 'assistant-mode-slash-outside-tools'\n | 'assistant-mode-subject-without-default'\n | 'assistant-mode-subject-default-twice'\n | 'assistant-mode-offered-without-label'\n params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[] }\n /** For a person reading the boot log; callers act on the code and params. */\n message: string\n}\n"]}
@@ -31,7 +31,20 @@ function planCarriedTurns(turns) {
31
31
  const content = (turn?.content ?? '').trim().slice(0, exports.CARRIED_CONTENT_LIMIT);
32
32
  if (!content)
33
33
  continue;
34
- const role = turn?.role === 'ai' || turn?.role === 'assistant' ? 'assistant' : turn?.role === 'user' ? 'user' : 'system';
34
+ /*
35
+ * 이 문은 **사람의 말과 어시스턴트의 답**만 옮긴다(ADR-0070 결정 ③). 그 밖의 역할은 받지 않는다 —
36
+ * `system` 행은 서버가 쓰는 자리이고, 모델은 그 줄을 사람의 말보다 무겁게 읽는다. 클라이언트가
37
+ * 「이것은 시스템이 한 말」이라고 적을 수 있으면 역할을 서버가 쓴다는 결정이 무너진다.
38
+ *
39
+ * 사람이 친 것이 아닌 맥락은 `appendSessionNote` 가 자리다 — 그 문은 서버가 `system` 으로 적는다.
40
+ */
41
+ const asked = turn?.role === 'ai' ? 'assistant' : turn?.role;
42
+ if (asked !== 'assistant' && asked !== 'user') {
43
+ throw new Error(`[ai-assistant/carried-turns] role '${String(turn?.role)}' cannot be carried. This door carries what ` +
44
+ "people said and what the assistant answered. Context that nobody typed goes through `appendSessionNote`, " +
45
+ 'which writes the system row itself.');
46
+ }
47
+ const role = asked;
35
48
  if (role === 'user') {
36
49
  planned.push({ role: 'user', content, addressee: 'room' });
37
50
  continue;
@@ -49,7 +62,6 @@ function planCarriedTurns(turns) {
49
62
  }
50
63
  continue;
51
64
  }
52
- planned.push({ role: 'system', content });
53
65
  }
54
66
  return planned.slice(-exports.CARRIED_TURN_LIMIT);
55
67
  }
@@ -1 +1 @@
1
- {"version":3,"file":"carried-turns.js","sourceRoot":"","sources":["../../../server/service/chat-message/carried-turns.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;AA6BH,4CA+BC;AAQD,gDAEC;AAWD,oDAKC;AAlED,4EAA4E;AAC/D,QAAA,kBAAkB,GAAG,EAAE,CAAA;AACvB,QAAA,qBAAqB,GAAG,IAAI,CAAA;AAEzC;;;;GAIG;AACH,SAAgB,gBAAgB,CAAC,KAAoB;IACnD,MAAM,OAAO,GAAiB,EAAE,CAAA;IAEhC,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QACrD,MAAM,OAAO,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,6BAAqB,CAAC,CAAA;QAC5E,IAAI,CAAC,OAAO;YAAE,SAAQ;QAEtB,MAAM,IAAI,GAAG,IAAI,EAAE,IAAI,KAAK,IAAI,IAAI,IAAI,EAAE,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAA;QAExH,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAA;YAC1D,SAAQ;QACV,CAAC;QAED,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;YACzB,IAAI,IAAI,EAAE,eAAe,EAAE,CAAC;gBAC1B,OAAO,CAAC,IAAI,CAAC;oBACX,IAAI,EAAE,WAAW;oBACjB,OAAO;oBACP,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE;iBAC7E,CAAC,CAAA;YACJ,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;YACxE,CAAC;YACD,SAAQ;QACV,CAAC;QAED,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAA;IAC3C,CAAC;IAED,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,0BAAkB,CAAC,CAAA;AAC3C,CAAC;AAED;;;;;GAKG;AACH,SAAgB,kBAAkB,CAAC,OAAe;IAChD,OAAO,qFAAqF,OAAO,GAAG,CAAA;AACxG,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,oBAAoB,CAAC,QAAgB,EAAE,MAAiD;IACtG,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,SAAS;QACpB,OAAO,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE;KAC9G,CAAA;AACH,CAAC","sourcesContent":["/*\n * A conversation carried in from somewhere else, turned into rows this session can hold.\n *\n * ── Why the assistant's turns are not simply written (ADR-0070 A15 decision ③) ──\n * `appendSessionTurns` took whatever roles the caller sent, so a client could write a row that\n * reads as the assistant speaking. Every participant then sees a sentence the assistant never\n * said, and the model reads it back as its own earlier turn. Roles are the server's to write.\n *\n * So a carried assistant turn is kept only when its original can be found — the caller names it\n * (`sourceSessionId` · `sourceMessageId`) and the server copies the stored text. Without an\n * original it is kept as a quotation on a `system` row: the content is preserved, and nobody is\n * told the assistant said it here.\n */\n\n/** One turn as the caller carried it over. */\nexport interface CarriedTurn {\n role?: string\n content?: string\n /** Where an assistant turn came from, so the server can read the original instead of trusting it. */\n sourceSessionId?: string\n sourceMessageId?: string\n}\n\nexport interface PlannedRow {\n role: 'user' | 'assistant' | 'system'\n content: string\n /** People's words are addressed to the room — this door is not the one that asks the assistant. */\n addressee?: 'room'\n /** Set when the row must be filled from a stored message before it is written. */\n copyOf?: { sessionId?: string; messageId: string }\n}\n\n/** How much of a carried conversation is kept — context, not an archive. */\nexport const CARRIED_TURN_LIMIT = 40\nexport const CARRIED_CONTENT_LIMIT = 4000\n\n/**\n * What to write, before anything is looked up.\n *\n * Pure: the caller resolves `copyOf` against storage and decides what a missing original becomes.\n */\nexport function planCarriedTurns(turns: CarriedTurn[]): PlannedRow[] {\n const planned: PlannedRow[] = []\n\n for (const turn of Array.isArray(turns) ? turns : []) {\n const content = (turn?.content ?? '').trim().slice(0, CARRIED_CONTENT_LIMIT)\n if (!content) continue\n\n const role = turn?.role === 'ai' || turn?.role === 'assistant' ? 'assistant' : turn?.role === 'user' ? 'user' : 'system'\n\n if (role === 'user') {\n planned.push({ role: 'user', content, addressee: 'room' })\n continue\n }\n\n if (role === 'assistant') {\n if (turn?.sourceMessageId) {\n planned.push({\n role: 'assistant',\n content,\n copyOf: { sessionId: turn.sourceSessionId, messageId: turn.sourceMessageId }\n })\n } else {\n planned.push({ role: 'system', content: quoteCarriedAnswer(content) })\n }\n continue\n }\n\n planned.push({ role: 'system', content })\n }\n\n return planned.slice(-CARRIED_TURN_LIMIT)\n}\n\n/**\n * An answer with no original, kept as a quotation.\n *\n * English, like the other lines the model reads (`[system]`, the omission notices): this row is\n * context for the model first, and a person reading it sees the quoted text either way.\n */\nexport function quoteCarriedAnswer(content: string): string {\n return `carried from another conversation, quoted — the assistant did not say this here: \"${content}\"`\n}\n\n/**\n * 원본을 찾는 조건.\n *\n * 문의 설명은 「`sourceSessionId` 와 `sourceMessageId` 로 원본을 댄다」고 말한다. 조회가 메시지 id 와\n * 테넌트만 보면, 서로 어긋난 두 인자가 와도 설명과 다르게 통과한다 — 설명과 코드가 갈리는 자리다\n * [수석 아키텍트가 ADR-0070 구현 검토에서 찾음, 2026-09-18].\n *\n * 테넌트는 언제나 건다. 대화를 대면 그것도 건다.\n */\nexport function carriedOriginalWhere(domainId: string, copyOf: { sessionId?: string; messageId: string }): any {\n return {\n id: copyOf.messageId,\n session: copyOf.sessionId ? { id: copyOf.sessionId, domain: { id: domainId } } : { domain: { id: domainId } }\n }\n}\n"]}
1
+ {"version":3,"file":"carried-turns.js","sourceRoot":"","sources":["../../../server/service/chat-message/carried-turns.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;AA6BH,4CA4CC;AAQD,gDAEC;AAWD,oDAKC;AA/ED,4EAA4E;AAC/D,QAAA,kBAAkB,GAAG,EAAE,CAAA;AACvB,QAAA,qBAAqB,GAAG,IAAI,CAAA;AAEzC;;;;GAIG;AACH,SAAgB,gBAAgB,CAAC,KAAoB;IACnD,MAAM,OAAO,GAAiB,EAAE,CAAA;IAEhC,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QACrD,MAAM,OAAO,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,6BAAqB,CAAC,CAAA;QAC5E,IAAI,CAAC,OAAO;YAAE,SAAQ;QAEtB;;;;;;WAMG;QACH,MAAM,KAAK,GAAG,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAA;QAC5D,IAAI,KAAK,KAAK,WAAW,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;YAC9C,MAAM,IAAI,KAAK,CACb,sCAAsC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,8CAA8C;gBACpG,2GAA2G;gBAC3G,qCAAqC,CACxC,CAAA;QACH,CAAC;QACD,MAAM,IAAI,GAAG,KAAK,CAAA;QAElB,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAA;YAC1D,SAAQ;QACV,CAAC;QAED,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;YACzB,IAAI,IAAI,EAAE,eAAe,EAAE,CAAC;gBAC1B,OAAO,CAAC,IAAI,CAAC;oBACX,IAAI,EAAE,WAAW;oBACjB,OAAO;oBACP,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE;iBAC7E,CAAC,CAAA;YACJ,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;YACxE,CAAC;YACD,SAAQ;QACV,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,0BAAkB,CAAC,CAAA;AAC3C,CAAC;AAED;;;;;GAKG;AACH,SAAgB,kBAAkB,CAAC,OAAe;IAChD,OAAO,qFAAqF,OAAO,GAAG,CAAA;AACxG,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,oBAAoB,CAAC,QAAgB,EAAE,MAAiD;IACtG,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,SAAS;QACpB,OAAO,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE;KAC9G,CAAA;AACH,CAAC","sourcesContent":["/*\n * A conversation carried in from somewhere else, turned into rows this session can hold.\n *\n * ── Why the assistant's turns are not simply written (ADR-0070 A15 decision ③) ──\n * `appendSessionTurns` took whatever roles the caller sent, so a client could write a row that\n * reads as the assistant speaking. Every participant then sees a sentence the assistant never\n * said, and the model reads it back as its own earlier turn. Roles are the server's to write.\n *\n * So a carried assistant turn is kept only when its original can be found — the caller names it\n * (`sourceSessionId` · `sourceMessageId`) and the server copies the stored text. Without an\n * original it is kept as a quotation on a `system` row: the content is preserved, and nobody is\n * told the assistant said it here.\n */\n\n/** One turn as the caller carried it over. */\nexport interface CarriedTurn {\n role?: string\n content?: string\n /** Where an assistant turn came from, so the server can read the original instead of trusting it. */\n sourceSessionId?: string\n sourceMessageId?: string\n}\n\nexport interface PlannedRow {\n role: 'user' | 'assistant' | 'system'\n content: string\n /** People's words are addressed to the room — this door is not the one that asks the assistant. */\n addressee?: 'room'\n /** Set when the row must be filled from a stored message before it is written. */\n copyOf?: { sessionId?: string; messageId: string }\n}\n\n/** How much of a carried conversation is kept — context, not an archive. */\nexport const CARRIED_TURN_LIMIT = 40\nexport const CARRIED_CONTENT_LIMIT = 4000\n\n/**\n * What to write, before anything is looked up.\n *\n * Pure: the caller resolves `copyOf` against storage and decides what a missing original becomes.\n */\nexport function planCarriedTurns(turns: CarriedTurn[]): PlannedRow[] {\n const planned: PlannedRow[] = []\n\n for (const turn of Array.isArray(turns) ? turns : []) {\n const content = (turn?.content ?? '').trim().slice(0, CARRIED_CONTENT_LIMIT)\n if (!content) continue\n\n /*\n * 이 문은 **사람의 말과 어시스턴트의 답**만 옮긴다(ADR-0070 결정 ③). 그 밖의 역할은 받지 않는다 —\n * `system` 행은 서버가 쓰는 자리이고, 모델은 그 줄을 사람의 말보다 무겁게 읽는다. 클라이언트가\n * 「이것은 시스템이 한 말」이라고 적을 수 있으면 역할을 서버가 쓴다는 결정이 무너진다.\n *\n * 사람이 친 것이 아닌 맥락은 `appendSessionNote` 가 자리다 — 그 문은 서버가 `system` 으로 적는다.\n */\n const asked = turn?.role === 'ai' ? 'assistant' : turn?.role\n if (asked !== 'assistant' && asked !== 'user') {\n throw new Error(\n `[ai-assistant/carried-turns] role '${String(turn?.role)}' cannot be carried. This door carries what ` +\n \"people said and what the assistant answered. Context that nobody typed goes through `appendSessionNote`, \" +\n 'which writes the system row itself.'\n )\n }\n const role = asked\n\n if (role === 'user') {\n planned.push({ role: 'user', content, addressee: 'room' })\n continue\n }\n\n if (role === 'assistant') {\n if (turn?.sourceMessageId) {\n planned.push({\n role: 'assistant',\n content,\n copyOf: { sessionId: turn.sourceSessionId, messageId: turn.sourceMessageId }\n })\n } else {\n planned.push({ role: 'system', content: quoteCarriedAnswer(content) })\n }\n continue\n }\n }\n\n return planned.slice(-CARRIED_TURN_LIMIT)\n}\n\n/**\n * An answer with no original, kept as a quotation.\n *\n * English, like the other lines the model reads (`[system]`, the omission notices): this row is\n * context for the model first, and a person reading it sees the quoted text either way.\n */\nexport function quoteCarriedAnswer(content: string): string {\n return `carried from another conversation, quoted — the assistant did not say this here: \"${content}\"`\n}\n\n/**\n * 원본을 찾는 조건.\n *\n * 문의 설명은 「`sourceSessionId` 와 `sourceMessageId` 로 원본을 댄다」고 말한다. 조회가 메시지 id 와\n * 테넌트만 보면, 서로 어긋난 두 인자가 와도 설명과 다르게 통과한다 — 설명과 코드가 갈리는 자리다\n * [수석 아키텍트가 ADR-0070 구현 검토에서 찾음, 2026-09-18].\n *\n * 테넌트는 언제나 건다. 대화를 대면 그것도 건다.\n */\nexport function carriedOriginalWhere(domainId: string, copyOf: { sessionId?: string; messageId: string }): any {\n return {\n id: copyOf.messageId,\n session: copyOf.sessionId ? { id: copyOf.sessionId, domain: { id: domainId } } : { domain: { id: domainId } }\n }\n}\n"]}
@@ -1,12 +1,24 @@
1
- import type { ToolPrivilege, ToolSpec } from '@things-factory/ai-client-base';
2
- /** 이 문을 지키는 권한 — 그 문에 닿는다고 선언한 도구에서 가져온다. */
3
- export declare function doorPrivilege(door: string, specs: readonly ToolSpec[]): ToolPrivilege | undefined;
1
+ import type { FieldGate } from '@things-factory/auth-base';
2
+ /** `privilegeOfField` 와 같은 모양 — 이 파일은 표를 **읽기만** 하고 어디서 오는지는 모른다. */
3
+ export type FieldLookup = (typeName: string, fieldName: string) => {
4
+ known: false;
5
+ } | {
6
+ known: true;
7
+ gate: FieldGate | null;
8
+ };
4
9
  export interface DoorCheckDeps {
5
- /** 등록된 도구 명세 전부 — 문→권한을 찾는 표 노릇. */
6
- specs: readonly ToolSpec[];
7
- /** AUTH 조회. `User.hasPrivilege` 를 그대로 싸서 넘긴다. */
8
- hasPrivilege(privilege: ToolPrivilege): Promise<boolean>;
10
+ /** AUTH 의 필드→권한 표. 운영에서는 `privilegeOfField` 가 들어온다(`reader-gate.ts`). */
11
+ lookup: FieldLookup;
12
+ /** `checkPermission` 을 그대로 싸서 넘긴다 — 판정은 AUTH 가 한다. */
13
+ permits(gate: FieldGate): Promise<boolean>;
9
14
  }
15
+ /** `Query.jobOrders` 를 타입과 필드로 가른다. 모양이 아니면 undefined — 아무도 못 지난다. */
16
+ export declare function doorField(door: string): {
17
+ typeName: string;
18
+ fieldName: string;
19
+ } | undefined;
20
+ /** 이 문이 스키마에 있는가 — 없으면 선언이 틀린 것이고, 부팅 보고가 그것을 말한다. */
21
+ export declare function isKnownDoor(door: string, lookup: FieldLookup): boolean;
10
22
  /**
11
23
  * 문 하나를 이 사람이 지나는가 — `doorGate` 에 넣을 검사.
12
24
  *
@@ -1,14 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.doorPrivilege = doorPrivilege;
3
+ exports.doorField = doorField;
4
+ exports.isKnownDoor = isKnownDoor;
4
5
  exports.doorCheck = doorCheck;
5
- /** 이 문을 지키는 권한 — 그 문에 닿는다고 선언한 도구에서 가져온다. */
6
- function doorPrivilege(door, specs) {
7
- for (const spec of specs) {
8
- if ((spec.doors ?? []).includes(door))
9
- return spec.privilege;
10
- }
11
- return undefined;
6
+ /** `Query.jobOrders` 를 타입과 필드로 가른다. 모양이 아니면 undefined — 아무도 못 지난다. */
7
+ function doorField(door) {
8
+ const match = /^(Query|Mutation|Subscription)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(door ?? '');
9
+ return match ? { typeName: match[1], fieldName: match[2] } : undefined;
10
+ }
11
+ /** 이 문이 스키마에 있는가 — 없으면 선언이 틀린 것이고, 부팅 보고가 그것을 말한다. */
12
+ function isKnownDoor(door, lookup) {
13
+ const field = doorField(door);
14
+ return !!field && lookup(field.typeName, field.fieldName).known;
12
15
  }
13
16
  /**
14
17
  * 문 하나를 이 사람이 지나는가 — `doorGate` 에 넣을 검사.
@@ -17,10 +20,15 @@ function doorPrivilege(door, specs) {
17
20
  */
18
21
  function doorCheck(deps) {
19
22
  return async (door) => {
20
- const privilege = doorPrivilege(door, deps.specs);
21
- if (!privilege)
23
+ const field = doorField(door);
24
+ if (!field)
25
+ return false;
26
+ const asked = deps.lookup(field.typeName, field.fieldName);
27
+ if (!asked.known)
22
28
  return false;
23
- return await deps.hasPrivilege(privilege);
29
+ if (asked.gate === null)
30
+ return true;
31
+ return await deps.permits(asked.gate);
24
32
  };
25
33
  }
26
34
  //# sourceMappingURL=door-access.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"door-access.js","sourceRoot":"","sources":["../../../server/service/chat-message/door-access.ts"],"names":[],"mappings":";;AAkBA,sCAKC;AAcD,8BAOC;AA3BD,6CAA6C;AAC7C,SAAgB,aAAa,CAAC,IAAY,EAAE,KAA0B;IACpE,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAE,IAAY,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,OAAQ,IAAY,CAAC,SAAS,CAAA;IAChF,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AASD;;;;GAIG;AACH,SAAgB,SAAS,CAAC,IAAmB;IAC3C,OAAO,KAAK,EAAE,IAAY,EAAE,EAAE;QAC5B,MAAM,SAAS,GAAG,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;QACjD,IAAI,CAAC,SAAS;YAAE,OAAO,KAAK,CAAA;QAE5B,OAAO,MAAM,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,CAAA;IAC3C,CAAC,CAAA;AACH,CAAC","sourcesContent":["/*\n * 문 → 그 문을 지키는 권한 — ADR-0073 판정 1 의 관문을 세우는 자리.\n *\n * ── 왜 도구 등록기에서 찾나 ───────────────────────────────────────────────\n * 행에 적힌 것은 문 이름(`Query.jobOrders`)이고, 사람이 가진 것은 권한(`job-order:query`)이다. 둘을\n * 잇는 정본은 AUTH 의 필드→권한 표이고, 그 표는 아직 없다(ADR-0072 결정 2 (b) — 수석 아키텍트가 청하기로\n * 되어 있다). 그때까지는 도구 명세가 이미 들고 있는 짝을 쓴다: 그 문에 닿는다고 선언한 도구가 자기\n * 권한도 함께 적어 두었다.\n *\n * **이것은 사본이다.** 표가 서면 `doorPrivilege` 하나만 그 표를 보게 바꾸고 나머지는 그대로 둔다.\n *\n * ── 모르는 문은 닫는다 ────────────────────────────────────────────────────\n * 어느 도구도 그 문을 선언하지 않았으면 무엇이 그 문을 지키는지 알 수 없다. 모를 때 열면, 도구가 사라진\n * 뒤에 남은 옛 답이 전부 공개된다 — 지우는 일이 여는 일이 된다. 닫는 쪽으로 넘어뜨린다.\n */\nimport type { ToolPrivilege, ToolSpec } from '@things-factory/ai-client-base'\n\n/** 이 문을 지키는 권한 — 그 문에 닿는다고 선언한 도구에서 가져온다. */\nexport function doorPrivilege(door: string, specs: readonly ToolSpec[]): ToolPrivilege | undefined {\n for (const spec of specs) {\n if (((spec as any).doors ?? []).includes(door)) return (spec as any).privilege\n }\n return undefined\n}\n\nexport interface DoorCheckDeps {\n /** 등록된 도구 명세 전부 — 문→권한을 찾는 표 노릇. */\n specs: readonly ToolSpec[]\n /** AUTH 조회. `User.hasPrivilege` 를 그대로 싸서 넘긴다. */\n hasPrivilege(privilege: ToolPrivilege): Promise<boolean>\n}\n\n/**\n * 문 하나를 이 사람이 지나는가 — `doorGate` 에 넣을 검사.\n *\n * 되풀이 조회를 줄이는 기억은 `doorGate` 가 한다. 여기는 한 번의 판정만 한다.\n */\nexport function doorCheck(deps: DoorCheckDeps): (door: string) => Promise<boolean> {\n return async (door: string) => {\n const privilege = doorPrivilege(door, deps.specs)\n if (!privilege) return false\n\n return await deps.hasPrivilege(privilege)\n }\n}\n"]}
1
+ {"version":3,"file":"door-access.js","sourceRoot":"","sources":["../../../server/service/chat-message/door-access.ts"],"names":[],"mappings":";;AAqCA,8BAGC;AAGD,kCAGC;AAOD,8BAWC;AA5BD,sEAAsE;AACtE,SAAgB,SAAS,CAAC,IAAY;IACpC,MAAM,KAAK,GAAG,2DAA2D,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAC1F,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;AACxE,CAAC;AAED,sDAAsD;AACtD,SAAgB,WAAW,CAAC,IAAY,EAAE,MAAmB;IAC3D,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;IAC7B,OAAO,CAAC,CAAC,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,KAAK,CAAA;AACjE,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,IAAmB;IAC3C,OAAO,KAAK,EAAE,IAAY,EAAE,EAAE;QAC5B,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;QAC7B,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QAExB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;QAC1D,IAAI,CAAC,KAAK,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QAC9B,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAA;QAEpC,OAAO,MAAM,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACvC,CAAC,CAAA;AACH,CAAC","sourcesContent":["/*\n * 문 → 그 문을 지키는 권한 — ADR-0073 판정 1 의 관문을 세우는 자리.\n *\n * ── 정본은 AUTH 의 표다 ───────────────────────────────────────────────────\n * 행에 적힌 것은 문 이름(`Query.jobOrders`)이고, 사람이 가진 것은 권한(`job-order:query`)이다. 둘을\n * 잇는 것은 `privilegeOfField(typeName, fieldName)` 이다(ADR-0072 결정 5). 스키마를 지을 때 모든 루트\n * 필드가 기록되므로, 이 표는 **그 문이 실제로 무엇을 묻는지**를 그대로 답한다.\n *\n * 그 전에는 도구 명세가 들고 있는 짝을 베껴 썼다. 베낀 값은 문이 선언을 바꾸면 조용히 어긋나고,\n * 어긋나면 느슨한 쪽이 이긴다. 표가 서면서 그 사본을 걷었다.\n *\n * ── 판정하지 않고 AUTH 에게 묻는다 ────────────────────────────────────────\n * 표가 주는 gate 는 `checkPermission` 이 받는 넷(category · privilege · owner · super)과 같은 모양이고,\n * 그대로 `checkPermission` 에 넘긴다. 여기서 「category 가 없으면 소유권만 본다」 같은 규칙을 다시 쓰면\n * 그 순간 AUTH 와 이 파일이 두 벌이 된다. 문에서 통하는 것이 여기서도 통해야 한다.\n *\n * ── 셋 중 둘은 닫는다 ─────────────────────────────────────────────────────\n * `{ known: false }` 그런 루트 필드가 없다. 도구가 문 이름을 틀리게 적은 것이다 —\n * **열림으로 읽지 않는다.** 부팅 보고가 따로 말한다.\n * `{ known: true, gate: null }` 필드는 있고 선언이 없다. 그 문은 열려 있으므로 이 행도 열린다.\n * `{ known: true, gate }` 그 문이 묻는 것을 이 사람이 가졌는지 AUTH 에게 묻는다.\n *\n * 스키마를 짓기 전에는 표가 비어 있어 모든 문이 `known: false` 로 답한다 — 가려지는 쪽이다.\n */\nimport type { FieldGate } from '@things-factory/auth-base'\n\n/** `privilegeOfField` 와 같은 모양 — 이 파일은 표를 **읽기만** 하고 어디서 오는지는 모른다. */\nexport type FieldLookup = (typeName: string, fieldName: string) => { known: false } | { known: true; gate: FieldGate | null }\n\nexport interface DoorCheckDeps {\n /** AUTH 의 필드→권한 표. 운영에서는 `privilegeOfField` 가 들어온다(`reader-gate.ts`). */\n lookup: FieldLookup\n /** `checkPermission` 을 그대로 싸서 넘긴다 — 판정은 AUTH 가 한다. */\n permits(gate: FieldGate): Promise<boolean>\n}\n\n/** `Query.jobOrders` 를 타입과 필드로 가른다. 모양이 아니면 undefined — 아무도 못 지난다. */\nexport function doorField(door: string): { typeName: string; fieldName: string } | undefined {\n const match = /^(Query|Mutation|Subscription)\\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(door ?? '')\n return match ? { typeName: match[1], fieldName: match[2] } : undefined\n}\n\n/** 이 문이 스키마에 있는가 — 없으면 선언이 틀린 것이고, 부팅 보고가 그것을 말한다. */\nexport function isKnownDoor(door: string, lookup: FieldLookup): boolean {\n const field = doorField(door)\n return !!field && lookup(field.typeName, field.fieldName).known\n}\n\n/**\n * 문 하나를 이 사람이 지나는가 — `doorGate` 에 넣을 검사.\n *\n * 되풀이 조회를 줄이는 기억은 `doorGate` 가 한다. 여기는 한 번의 판정만 한다.\n */\nexport function doorCheck(deps: DoorCheckDeps): (door: string) => Promise<boolean> {\n return async (door: string) => {\n const field = doorField(door)\n if (!field) return false\n\n const asked = deps.lookup(field.typeName, field.fieldName)\n if (!asked.known) return false\n if (asked.gate === null) return true\n\n return await deps.permits(asked.gate)\n }\n}\n"]}
@@ -8,7 +8,6 @@ exports.readerGate = readerGate;
8
8
  * 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날
9
9
  * 그 문으로 샌다.
10
10
  */
11
- const ai_client_base_1 = require("@things-factory/ai-client-base");
12
11
  const auth_base_1 = require("@things-factory/auth-base");
13
12
  const door_access_js_1 = require("./door-access.js");
14
13
  const message_doors_js_1 = require("./message-doors.js");
@@ -17,9 +16,12 @@ const request_translator_js_1 = require("../request-translator.js");
17
16
  function readerGate(context) {
18
17
  const { domain, user } = context.state;
19
18
  const t = (0, request_translator_js_1.requestTranslator)(context);
20
- const gate = (0, message_doors_js_1.doorGate)((0, door_access_js_1.doorCheck)({
21
- specs: (0, ai_client_base_1.getAllToolSpecs)(),
22
- hasPrivilege: privilege => auth_base_1.User.hasPrivilege(privilege.privilege, privilege.category, domain, user)
19
+ const { unsafeIP, prohibitedPrivileges } = context.state;
20
+ const gate = (0, message_doors_js_1.doorGate)(
21
+ /* 문에서 쓰는 판정을 그대로 쓴다 — 같은 인자, 같은 함수(ADR-0072 결정 5). */
22
+ (0, door_access_js_1.doorCheck)({
23
+ lookup: auth_base_1.privilegeOfField,
24
+ permits: fieldGate => (0, auth_base_1.checkPermission)(fieldGate, user, domain, unsafeIP, prohibitedPrivileges)
23
25
  }));
24
26
  /*
25
27
  * 문 이름(`Query.productionReports`)은 이 문장에 넣지 않는다.
@@ -1 +1 @@
1
- {"version":3,"file":"reader-gate.js","sourceRoot":"","sources":["../../../server/service/chat-message/reader-gate.ts"],"names":[],"mappings":";;AAeA,gCA0BC;AAzCD;;;;;;GAMG;AACH,mEAAgE;AAChE,yDAAgD;AAEhD,qDAA4C;AAC5C,yDAAkF;AAClF,oEAA4D;AAE5D,6CAA6C;AAC7C,SAAgB,UAAU,CAAC,OAAY;IACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IACtC,MAAM,CAAC,GAAG,IAAA,yCAAiB,EAAC,OAAO,CAAC,CAAA;IAEpC,MAAM,IAAI,GAAG,IAAA,2BAAQ,EACnB,IAAA,0BAAS,EAAC;QACR,KAAK,EAAE,IAAA,gCAAe,GAAE;QACxB,YAAY,EAAE,SAAS,CAAC,EAAE,CAAC,gBAAI,CAAC,YAAY,CAAC,SAAS,CAAC,SAAS,EAAE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC;KACpG,CAAC,CACH,CAAA;IAED;;;;;;;OAOG;IACH,OAAO,IAAI,CAAC,EAAE,CACZ,IAAA,gCAAa,EAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,CAC7B,CAAC,CAAC,2CAA2C,EAAE;QAC7C,mDAAmD;QACnD,YAAY,EAAE,iFAAiF;KAChG,CAAC,CACH,CAAA;AACL,CAAC","sourcesContent":["/*\n * 읽는 사람의 관문 — ADR-0073 판정 1·2 를 요청 하나에 세운다.\n *\n * 리졸버는 이것을 한 번 만들고 행 목록을 통과시킨다. 문 판정은 (사람, 문) 별로 한 번만 일어나고,\n * 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날\n * 그 문으로 샌다.\n */\nimport { getAllToolSpecs } from '@things-factory/ai-client-base'\nimport { User } from '@things-factory/auth-base'\n\nimport { doorCheck } from './door-access.js'\nimport { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'\nimport { requestTranslator } from '../request-translator.js'\n\n/** 이 요청에서 행을 가려 내보내는 함수. 같은 요청 안에서 계속 쓴다. */\nexport function readerGate(context: any): <T extends DoorReadableRow>(rows: readonly T[]) => Promise<T[]> {\n const { domain, user } = context.state\n const t = requestTranslator(context)\n\n const gate = doorGate(\n doorCheck({\n specs: getAllToolSpecs(),\n hasPrivilege: privilege => User.hasPrivilege(privilege.privilege, privilege.category, domain, user)\n })\n )\n\n /*\n * 문 이름(`Query.productionReports`)은 이 문장에 넣지 않는다.\n *\n * 현장에서 읽는 사람에게 GraphQL 이름은 아무 말도 아니다. 문 이름을 사람의 말로 바꿀 표는 AUTH 의\n * 필드→권한 표와 같은 자리에서 나와야 하고, 그것은 아직 없다(ADR-0072 결정 2 (b)). 없는 표를 흉내\n * 내느니 문장은 사실만 말하고, 목록은 `ChatMessage.doors` 로 따로 나간다 — 관리자에게 무엇을\n * 청할지 알아야 하는 화면은 거기서 읽는다. 문구는 UX 레인이 정한다.\n */\n return rows =>\n redactByDoors(rows, gate, () =>\n t('ai-assistant.text.answer-needs-permission', {\n /* 구독에는 요청 번역기가 없다 — `request-translator.ts` 참조. */\n defaultValue: 'you do not have permission to see this answer. ask an administrator for access.'\n })\n )\n}\n"]}
1
+ {"version":3,"file":"reader-gate.js","sourceRoot":"","sources":["../../../server/service/chat-message/reader-gate.ts"],"names":[],"mappings":";;AAcA,gCA6BC;AA3CD;;;;;;GAMG;AACH,yDAA6E;AAE7E,qDAA4C;AAC5C,yDAAkF;AAClF,oEAA4D;AAE5D,6CAA6C;AAC7C,SAAgB,UAAU,CAAC,OAAY;IACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IACtC,MAAM,CAAC,GAAG,IAAA,yCAAiB,EAAC,OAAO,CAAC,CAAA;IAEpC,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IAExD,MAAM,IAAI,GAAG,IAAA,2BAAQ;IACnB,sDAAsD;IACtD,IAAA,0BAAS,EAAC;QACR,MAAM,EAAE,4BAAgB;QACxB,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC,IAAA,2BAAe,EAAC,SAAgB,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,CAAC;KACtG,CAAC,CACH,CAAA;IAED;;;;;;;OAOG;IACH,OAAO,IAAI,CAAC,EAAE,CACZ,IAAA,gCAAa,EAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,CAC7B,CAAC,CAAC,2CAA2C,EAAE;QAC7C,mDAAmD;QACnD,YAAY,EAAE,iFAAiF;KAChG,CAAC,CACH,CAAA;AACL,CAAC","sourcesContent":["/*\n * 읽는 사람의 관문 — ADR-0073 판정 1·2 를 요청 하나에 세운다.\n *\n * 리졸버는 이것을 한 번 만들고 행 목록을 통과시킨다. 문 판정은 (사람, 문) 별로 한 번만 일어나고,\n * 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날\n * 그 문으로 샌다.\n */\nimport { checkPermission, privilegeOfField } from '@things-factory/auth-base'\n\nimport { doorCheck } from './door-access.js'\nimport { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'\nimport { requestTranslator } from '../request-translator.js'\n\n/** 이 요청에서 행을 가려 내보내는 함수. 같은 요청 안에서 계속 쓴다. */\nexport function readerGate(context: any): <T extends DoorReadableRow>(rows: readonly T[]) => Promise<T[]> {\n const { domain, user } = context.state\n const t = requestTranslator(context)\n\n const { unsafeIP, prohibitedPrivileges } = context.state\n\n const gate = doorGate(\n /* 문에서 쓰는 판정을 그대로 쓴다 — 같은 인자, 같은 함수(ADR-0072 결정 5). */\n doorCheck({\n lookup: privilegeOfField,\n permits: fieldGate => checkPermission(fieldGate as any, user, domain, unsafeIP, prohibitedPrivileges)\n })\n )\n\n /*\n * 문 이름(`Query.productionReports`)은 이 문장에 넣지 않는다.\n *\n * 현장에서 읽는 사람에게 GraphQL 이름은 아무 말도 아니다. 문 이름을 사람의 말로 바꿀 표는 AUTH 의\n * 필드→권한 표와 같은 자리에서 나와야 하고, 그것은 아직 없다(ADR-0072 결정 2 (b)). 없는 표를 흉내\n * 내느니 문장은 사실만 말하고, 목록은 `ChatMessage.doors` 로 따로 나간다 — 관리자에게 무엇을\n * 청할지 알아야 하는 화면은 거기서 읽는다. 문구는 UX 레인이 정한다.\n */\n return rows =>\n redactByDoors(rows, gate, () =>\n t('ai-assistant.text.answer-needs-permission', {\n /* 구독에는 요청 번역기가 없다 — `request-translator.ts` 참조. */\n defaultValue: 'you do not have permission to see this answer. ask an administrator for access.'\n })\n )\n}\n"]}
@@ -40,6 +40,18 @@ export declare class ChatSession {
40
40
  * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).
41
41
  */
42
42
  lastMessageAt?: Date;
43
+ /**
44
+ * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).
45
+ *
46
+ * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`
47
+ * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야
48
+ * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로
49
+ * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).
50
+ *
51
+ * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
52
+ * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
53
+ */
54
+ kind?: string;
43
55
  /**
44
56
  * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
45
57
  *
@@ -91,22 +91,23 @@ tslib_1.__decorate([
91
91
  (0, type_graphql_1.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).' }),
92
92
  tslib_1.__metadata("design:type", Date
93
93
  /**
94
- * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
94
+ * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).
95
95
  *
96
- * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
97
- * `private` 참여자만 읽는다.
96
+ * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`
97
+ * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야
98
+ * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로
99
+ * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).
98
100
  *
99
- * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로
100
- * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을
101
- * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면
102
- * 사적으로 오간 원문이 소급해서 공개된다.
103
- *
104
- * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.
105
- *
106
- * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
101
+ * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
102
+ * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
107
103
  */
108
104
  )
109
105
  ], ChatSession.prototype, "lastMessageAt", void 0);
106
+ tslib_1.__decorate([
107
+ (0, typeorm_1.Column)({ type: 'varchar', length: 64, nullable: true }),
108
+ (0, type_graphql_1.Field)({ nullable: true, description: "Which conversation this session is — the registered mode kind. Written by the server, never sent by the client." }),
109
+ tslib_1.__metadata("design:type", String)
110
+ ], ChatSession.prototype, "kind", void 0);
110
111
  tslib_1.__decorate([
111
112
  (0, typeorm_1.Column)({ type: 'varchar', length: 16, default: 'open' }),
112
113
  (0, type_graphql_1.Field)({ description: "Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only)." }),
@@ -1 +1 @@
1
- {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @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).' })\n lastMessageAt?: Date\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
1
+ {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,qCAWgB;AAChB,+CAAoD;AAEpD,iDAA8C;AAC9C,yDAAgD;AAChD,6CAA4C;AAE5C,qEAA6D;AAE7D,MAAM,SAAS,GAAG,YAAM,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;AAC7C,MAAM,aAAa,GAAG,SAAS,CAAC,IAAI,CAAA;AAW7B,IAAM,WAAW,GAAjB,MAAM,WAAW;CA8LvB,CAAA;AA9LY,kCAAW;AAGb;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;uCAClB;AAIpB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACjC,cAAM;2CAAA;AAGf;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;;6CACpC;AAYjB;IANC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC;QACL,QAAQ,EAAE,IAAI;QACd,WAAW,EACT,gMAAgM;KACnM,CAAC;;4CACc;AAehB;IANC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC;QACL,QAAQ,EAAE,IAAI;QACd,WAAW,EACT,gMAAgM;KACnM,CAAC;;+CACiB;AAKnB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gEAAgE,EAAE,CAAC;;6CACxF;AAUjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,+DAA+D,EAAE,CAAC;;yCAC3F;AAIb;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gCAAgC,EAAE,CAAC;sCAC7E,gBAAI;4CAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;8CACpC;AAIlB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qCAAqC,EAAE,CAAC;sCAClF,gBAAI;4CAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;8CACpC;AAYlB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wKAAwK,EAAE,CAAC;sCACjM,IAAI;IAEpB;;;;;;;;;;OAUG;;kDAZiB;AAepB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACvD,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,iHAAiH,EAAE,CAAC;;yCAC7I;AAmBb;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IACxD,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,iGAAiG,EAAE,CAAC;;+CACvG;AAKnB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,gGAAgG,EAAE,CAAC;;uDAC9G;AAW3B;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAChD,IAAA,oBAAK,EAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,yGAAyG,EAAE,CAAC;;qDACvI;AAe3B;IAbC,IAAA,gBAAM,EAAC;QACN,QAAQ,EAAE,IAAI;QACd,IAAI,EACF,aAAa,IAAI,OAAO,IAAI,aAAa,IAAI,SAAS;YACpD,CAAC,CAAC,UAAU;YACZ,CAAC,CAAC,aAAa,IAAI,QAAQ;gBACzB,CAAC,CAAC,MAAM;gBACR,CAAC,CAAC,aAAa,IAAI,OAAO;oBACxB,CAAC,CAAC,UAAU;oBACZ,CAAC,CAAC,MAAM;QAChB,MAAM,EAAE,aAAa,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;KACrD,CAAC;IACD,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,0DAA0D,EAAE,CAAC;;gDAC/E;AAWpB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,6HAA6H,EAAE,CAAC;;yDACzI;AAK7B;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;+CACP;AAInB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACd,IAAI;8CAAA;AAIhB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACd,IAAI;8CAAA;AAGhB;IADC,IAAA,0BAAgB,GAAE;sCACP,IAAI;IAEhB,oDAAoD;;8CAFpC;AAIhB;IADC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,6BAAW,EAAE,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;;6CACnC;sBA9Kb,WAAW;IATvB,IAAA,gBAAM,GAAE;IACT,qEAAqE;IACrE,kDAAkD;;IACjD,IAAA,eAAK,EAAC,mBAAmB,EAAE,CAAC,OAAoB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACxF,uEAAuE;;IACtE,IAAA,eAAK,EAAC,mBAAmB,EAAE,CAAC,OAAoB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5G,IAAA,yBAAU,EAAC;QACV,WAAW,EAAE,2HAA2H;KACzI,CAAC;GACW,WAAW,CA8LvB","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @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).' })\n lastMessageAt?: Date\n\n /**\n * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).\n *\n * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`\n * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야\n * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로\n * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는\n * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.\n */\n @Column({ type: 'varchar', length: 64, nullable: true })\n @Field({ nullable: true, description: \"Which conversation this session is — the registered mode kind. Written by the server, never sent by the client.\" })\n kind?: string\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
@@ -23,4 +23,13 @@ export declare function anchorColumns(anchorType: string | undefined, anchorId:
23
23
  * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
24
24
  * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
25
25
  */
26
- export declare function anchorWhere(domainId: string, anchorType: string, anchorId: string): any;
26
+ /**
27
+ * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).
28
+ *
29
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영
30
+ * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이
31
+ * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).
32
+ *
33
+ * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.
34
+ */
35
+ export declare function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any;
@@ -93,10 +93,18 @@ function anchorColumns(anchorType, anchorId) {
93
93
  * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
94
94
  * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
95
95
  */
96
- function anchorWhere(domainId, anchorType, anchorId) {
96
+ /**
97
+ * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).
98
+ *
99
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영
100
+ * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이
101
+ * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).
102
+ *
103
+ * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.
104
+ */
105
+ function anchorWhere(domainId, anchorType, anchorId, kind) {
97
106
  const type = assertAnchorType(anchorType, 'anchorWhere');
98
- if (type === 'board')
99
- return { boardId: anchorId, domain: { id: domainId } };
100
- return { anchorType: type, anchorId, domain: { id: domainId } };
107
+ const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } };
108
+ return kind ? { ...where, kind } : where;
101
109
  }
102
110
  //# sourceMappingURL=session-anchor.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;AAiBH,kDAEC;AAiBD,sCAeC;AASD,kCAIC;AA9DD;;;;;;;;GAQG;AACH,2EAA2E;AAC3E,6EAA6E;AAChE,QAAA,oBAAoB,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAU,CAAA;AAItH,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,4BAA0C,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACjG,CAAC;AAED,SAAS,gBAAgB,CAAC,UAA8B,EAAE,KAAa;IACrE,MAAM,IAAI,GAAG,UAAU,IAAI,MAAM,CAAA;IACjC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,6BAA6B,KAAK,MAAM,IAAI,2BAA2B;YACrE,YAAY,4BAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI;YAChD,gGAAgG;YAChG,2EAA2E;YAC3E,4EAA4E,CAC/E,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,oEAAoE;AACpE,SAAgB,aAAa,CAC3B,UAA8B,EAC9B,QAA4B;IAE5B,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,QAAQ,IAAI,SAAS,CAAA;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,oDAAoD,IAAI,qBAAqB;YAC3E,+FAA+F;YAC/F,gGAAgG;YAChG,+EAA+E,CAClF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;AACvF,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,WAAW,CAAC,QAAgB,EAAE,UAAkB,EAAE,QAAgB;IAChF,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;IACxD,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;IAC5E,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;AACjE,CAAC","sourcesContent":["/*\n * 세션 앵커 순수 로직 — 리졸버와 분리.\n *\n * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel\n * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·\n * chat-input-builder 와 같은 관례).\n *\n * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서\n * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영\n * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.\n *\n * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board\n * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께\n * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.\n *\n * ── Why this file validates instead of casting (2026-09-09) ──────────────────────\n * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a\n * check: the type existed and nothing enforced it, so any string reached a nullable varchar and\n * was stored. The plant lane was about to add a product and stopped to ask which value to write,\n * which is the only reason this was noticed — the column would have accepted whatever it sent.\n *\n * A wrong value here is not a wrong value in one row. The anchor decides which conversations are\n * listed together and which history a reply may see, so a typo silently creates a second,\n * invisible bucket: the session is written, the inbox query for the real anchor does not match\n * it, and the conversation is simply gone from the list with no error anywhere.\n *\n * Two rules follow, and both are refusals rather than defaults:\n *\n * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it\n * turns \"you named something that does not exist\" into \"this conversation is about nothing\".\n * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points\n * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored\n * NULL — the lookup misses its own row and every call creates another session.\n *\n * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for\n * \"nothing picked yet\" (twin's dock refuses to create a session while it holds 'none'), not a\n * value a product writes because it has nothing narrower to point at. A product whose subject is\n * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and\n * has a history boundary, and \"unanchored\" has neither.\n *\n * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new\n * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it\n * orphans them.\n */\n\n/**\n * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**\n *\n * board 보드 저작 대화 — 그 보드의 편집 이력\n * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들\n * instance 트윈 하나에 대한 대화\n * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때\n * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다\n */\n// Figure-specific history requested explicitly: reuse the existing varchar\n// anchor columns, without writing Figure ids into the legacy boardId column.\nexport const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const\n\nexport type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]\n\nexport function isSessionAnchorType(value: unknown): value is SessionAnchorType {\n return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)\n}\n\nfunction assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {\n const type = anchorType || 'none'\n if (!isSessionAnchorType(type)) {\n throw new Error(\n `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +\n `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +\n 'The anchor decides which conversations are listed together and which history a reply may see, ' +\n 'so an unrecognised value would write a session the inbox can never find. ' +\n 'If a new kind of subject is needed, raise it — this set is stored in rows.'\n )\n }\n return type\n}\n\n/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */\nexport function anchorColumns(\n anchorType: string | undefined,\n anchorId: string | undefined\n): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {\n const type = assertAnchorType(anchorType, 'anchorColumns')\n const id = anchorId || undefined\n if (type !== 'none' && !id) {\n throw new Error(\n `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +\n 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +\n 'given — the session would not be found by its own anchor and every call would create another. ' +\n \"Use 'none' for a conversation that is deliberately not addressable by anchor.\"\n )\n }\n return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }\n}\n\n/**\n * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다\n * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.\n *\n * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,\n * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.\n */\nexport function anchorWhere(domainId: string, anchorType: string, anchorId: string): any {\n const type = assertAnchorType(anchorType, 'anchorWhere')\n if (type === 'board') return { boardId: anchorId, domain: { id: domainId } }\n return { anchorType: type, anchorId, domain: { id: domainId } }\n}\n"]}
1
+ {"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;AAiBH,kDAEC;AAiBD,sCAeC;AAkBD,kCAIC;AAvED;;;;;;;;GAQG;AACH,2EAA2E;AAC3E,6EAA6E;AAChE,QAAA,oBAAoB,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAU,CAAA;AAItH,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,4BAA0C,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACjG,CAAC;AAED,SAAS,gBAAgB,CAAC,UAA8B,EAAE,KAAa;IACrE,MAAM,IAAI,GAAG,UAAU,IAAI,MAAM,CAAA;IACjC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,6BAA6B,KAAK,MAAM,IAAI,2BAA2B;YACrE,YAAY,4BAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI;YAChD,gGAAgG;YAChG,2EAA2E;YAC3E,4EAA4E,CAC/E,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,oEAAoE;AACpE,SAAgB,aAAa,CAC3B,UAA8B,EAC9B,QAA4B;IAE5B,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,QAAQ,IAAI,SAAS,CAAA;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,oDAAoD,IAAI,qBAAqB;YAC3E,+FAA+F;YAC/F,gGAAgG;YAChG,+EAA+E,CAClF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;AACvF,CAAC;AAED;;;;;;GAMG;AACH;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,QAAgB,EAAE,UAAkB,EAAE,QAAgB,EAAE,IAAa;IAC/F,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;IACxD,MAAM,KAAK,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;IAC3I,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;AAC1C,CAAC","sourcesContent":["/*\n * 세션 앵커 순수 로직 — 리졸버와 분리.\n *\n * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel\n * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·\n * chat-input-builder 와 같은 관례).\n *\n * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서\n * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영\n * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.\n *\n * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board\n * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께\n * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.\n *\n * ── Why this file validates instead of casting (2026-09-09) ──────────────────────\n * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a\n * check: the type existed and nothing enforced it, so any string reached a nullable varchar and\n * was stored. The plant lane was about to add a product and stopped to ask which value to write,\n * which is the only reason this was noticed — the column would have accepted whatever it sent.\n *\n * A wrong value here is not a wrong value in one row. The anchor decides which conversations are\n * listed together and which history a reply may see, so a typo silently creates a second,\n * invisible bucket: the session is written, the inbox query for the real anchor does not match\n * it, and the conversation is simply gone from the list with no error anywhere.\n *\n * Two rules follow, and both are refusals rather than defaults:\n *\n * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it\n * turns \"you named something that does not exist\" into \"this conversation is about nothing\".\n * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points\n * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored\n * NULL — the lookup misses its own row and every call creates another session.\n *\n * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for\n * \"nothing picked yet\" (twin's dock refuses to create a session while it holds 'none'), not a\n * value a product writes because it has nothing narrower to point at. A product whose subject is\n * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and\n * has a history boundary, and \"unanchored\" has neither.\n *\n * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new\n * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it\n * orphans them.\n */\n\n/**\n * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**\n *\n * board 보드 저작 대화 — 그 보드의 편집 이력\n * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들\n * instance 트윈 하나에 대한 대화\n * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때\n * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다\n */\n// Figure-specific history requested explicitly: reuse the existing varchar\n// anchor columns, without writing Figure ids into the legacy boardId column.\nexport const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const\n\nexport type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]\n\nexport function isSessionAnchorType(value: unknown): value is SessionAnchorType {\n return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)\n}\n\nfunction assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {\n const type = anchorType || 'none'\n if (!isSessionAnchorType(type)) {\n throw new Error(\n `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +\n `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +\n 'The anchor decides which conversations are listed together and which history a reply may see, ' +\n 'so an unrecognised value would write a session the inbox can never find. ' +\n 'If a new kind of subject is needed, raise it — this set is stored in rows.'\n )\n }\n return type\n}\n\n/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */\nexport function anchorColumns(\n anchorType: string | undefined,\n anchorId: string | undefined\n): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {\n const type = assertAnchorType(anchorType, 'anchorColumns')\n const id = anchorId || undefined\n if (type !== 'none' && !id) {\n throw new Error(\n `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +\n 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +\n 'given — the session would not be found by its own anchor and every call would create another. ' +\n \"Use 'none' for a conversation that is deliberately not addressable by anchor.\"\n )\n }\n return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }\n}\n\n/**\n * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다\n * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.\n *\n * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,\n * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.\n */\n/**\n * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).\n *\n * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영\n * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이\n * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.\n */\nexport function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {\n const type = assertAnchorType(anchorType, 'anchorWhere')\n const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }\n return kind ? { ...where, kind } : where\n}\n"]}
@@ -2,6 +2,8 @@
2
2
  export interface InboxSessionRow {
3
3
  id?: string;
4
4
  name?: string;
5
+ /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */
6
+ kind?: string;
5
7
  anchorType?: string;
6
8
  anchorId?: string;
7
9
  boardId?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n }\n\n const entries = sessions.map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
1
+ {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n }\n\n const entries = sessions.map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
@@ -0,0 +1,14 @@
1
+ export type SessionKindVerdict = {
2
+ action: 'proceed';
3
+ } | {
4
+ action: 'stamp';
5
+ kind: string;
6
+ } | {
7
+ action: 'refuse';
8
+ errorCode: 'session-kind-mismatch';
9
+ params: {
10
+ sessionKind: string;
11
+ requested: string;
12
+ };
13
+ };
14
+ export declare function judgeSessionKind(sessionKind: string | null | undefined, requested: string): SessionKindVerdict;