@things-factory/ai-assistant 10.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatMessage = void 0;
4
+ const tslib_1 = require("tslib");
5
+ /**
6
+ * ChatMessage — ChatSession 의 한 메시지.
7
+ *
8
+ * role: 'user' | 'assistant' | 'system'
9
+ * 'system' 은 사용자 직접 편집/import 진행 등 시스템이 자동 생성하는 컨텍스트 메시지.
10
+ *
11
+ * relatedPatchId / relatedImportSessionId 로 메시지가 트리거한 패치·임포트와 연결.
12
+ */
13
+ const typeorm_1 = require("typeorm");
14
+ const type_graphql_1 = require("type-graphql");
15
+ const graphql_scalars_1 = require("graphql-scalars");
16
+ const auth_base_1 = require("@things-factory/auth-base");
17
+ const env_1 = require("@things-factory/env");
18
+ const chat_session_js_1 = require("../chat-session/chat-session.js");
19
+ const ORMCONFIG = env_1.config.get('ormconfig', {});
20
+ const DATABASE_TYPE = ORMCONFIG.type;
21
+ let ChatMessage = class ChatMessage {
22
+ /** GraphQL 노출용 — JSON 배열로 parse. */
23
+ get toolUsagesJson() {
24
+ if (!this.toolUsages)
25
+ return null;
26
+ try {
27
+ return JSON.parse(this.toolUsages);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** GraphQL 노출용 — 식별자 배열로 parse. */
34
+ get groundingWarningsJson() {
35
+ if (!this.groundingWarnings)
36
+ return null;
37
+ try {
38
+ return JSON.parse(this.groundingWarnings);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ };
45
+ exports.ChatMessage = ChatMessage;
46
+ tslib_1.__decorate([
47
+ (0, typeorm_1.PrimaryGeneratedColumn)('uuid'),
48
+ (0, type_graphql_1.Field)(type => type_graphql_1.ID, { nullable: true }),
49
+ tslib_1.__metadata("design:type", String)
50
+ ], ChatMessage.prototype, "id", void 0);
51
+ tslib_1.__decorate([
52
+ (0, typeorm_1.ManyToOne)(type => chat_session_js_1.ChatSession, session => session.messages, { onDelete: 'CASCADE' }),
53
+ tslib_1.__metadata("design:type", chat_session_js_1.ChatSession)
54
+ ], ChatMessage.prototype, "session", void 0);
55
+ tslib_1.__decorate([
56
+ (0, typeorm_1.RelationId)((message) => message.session),
57
+ tslib_1.__metadata("design:type", String)
58
+ ], ChatMessage.prototype, "sessionId", void 0);
59
+ tslib_1.__decorate([
60
+ (0, typeorm_1.Column)({ type: 'varchar', length: 16 }),
61
+ (0, type_graphql_1.Field)({ description: "'user' | 'assistant' | 'system'" }),
62
+ tslib_1.__metadata("design:type", String)
63
+ ], ChatMessage.prototype, "role", void 0);
64
+ tslib_1.__decorate([
65
+ (0, typeorm_1.Column)({
66
+ type: DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
67
+ ? 'longtext'
68
+ : DATABASE_TYPE == 'oracle'
69
+ ? 'clob'
70
+ : DATABASE_TYPE == 'mssql'
71
+ ? 'nvarchar'
72
+ : 'text',
73
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
74
+ }),
75
+ (0, type_graphql_1.Field)({ description: 'Message content' }),
76
+ tslib_1.__metadata("design:type", String)
77
+ ], ChatMessage.prototype, "content", void 0);
78
+ tslib_1.__decorate([
79
+ (0, typeorm_1.Column)({ nullable: true }),
80
+ (0, type_graphql_1.Field)({ nullable: true, description: 'PatchEntry.id this message triggered (if any).' }),
81
+ tslib_1.__metadata("design:type", String)
82
+ ], ChatMessage.prototype, "relatedPatchId", void 0);
83
+ tslib_1.__decorate([
84
+ (0, typeorm_1.Column)({ nullable: true }),
85
+ (0, type_graphql_1.Field)({ nullable: true, description: 'ImportSession.id this message triggered (if any).' }),
86
+ tslib_1.__metadata("design:type", String)
87
+ ], ChatMessage.prototype, "relatedImportSessionId", void 0);
88
+ tslib_1.__decorate([
89
+ (0, typeorm_1.Column)({
90
+ type: DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
91
+ ? 'longtext'
92
+ : DATABASE_TYPE == 'oracle'
93
+ ? 'clob'
94
+ : DATABASE_TYPE == 'mssql'
95
+ ? 'nvarchar'
96
+ : 'text',
97
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
98
+ nullable: true
99
+ }),
100
+ tslib_1.__metadata("design:type", String)
101
+ ], ChatMessage.prototype, "toolUsages", void 0);
102
+ tslib_1.__decorate([
103
+ (0, type_graphql_1.Field)(() => graphql_scalars_1.GraphQLJSON, {
104
+ nullable: true,
105
+ description: 'Tool usage trace — array of {name, arguments, result, kind}. Only on assistant messages.'
106
+ }),
107
+ tslib_1.__metadata("design:type", Object),
108
+ tslib_1.__metadata("design:paramtypes", [])
109
+ ], ChatMessage.prototype, "toolUsagesJson", null);
110
+ tslib_1.__decorate([
111
+ (0, typeorm_1.Column)({ type: 'varchar', length: 1024, nullable: true }),
112
+ tslib_1.__metadata("design:type", String)
113
+ ], ChatMessage.prototype, "groundingWarnings", void 0);
114
+ tslib_1.__decorate([
115
+ (0, type_graphql_1.Field)(() => graphql_scalars_1.GraphQLJSON, {
116
+ nullable: true,
117
+ description: 'Ungrounded identifiers mentioned by this reply (hallucination candidates). Null when grounded.'
118
+ }),
119
+ tslib_1.__metadata("design:type", Object),
120
+ tslib_1.__metadata("design:paramtypes", [])
121
+ ], ChatMessage.prototype, "groundingWarningsJson", null);
122
+ tslib_1.__decorate([
123
+ (0, typeorm_1.ManyToOne)(type => auth_base_1.User, { nullable: true }),
124
+ (0, type_graphql_1.Field)(type => auth_base_1.User, { nullable: true, description: 'User who triggered this message.' }),
125
+ tslib_1.__metadata("design:type", auth_base_1.User)
126
+ ], ChatMessage.prototype, "creator", void 0);
127
+ tslib_1.__decorate([
128
+ (0, typeorm_1.RelationId)((message) => message.creator),
129
+ tslib_1.__metadata("design:type", String)
130
+ ], ChatMessage.prototype, "creatorId", void 0);
131
+ tslib_1.__decorate([
132
+ (0, typeorm_1.ManyToOne)(type => auth_base_1.User, { nullable: true }),
133
+ (0, type_graphql_1.Field)(type => auth_base_1.User, { nullable: true, description: 'Last user to update this message (for future edit support).' }),
134
+ tslib_1.__metadata("design:type", auth_base_1.User)
135
+ ], ChatMessage.prototype, "updater", void 0);
136
+ tslib_1.__decorate([
137
+ (0, typeorm_1.RelationId)((message) => message.updater),
138
+ tslib_1.__metadata("design:type", String)
139
+ ], ChatMessage.prototype, "updaterId", void 0);
140
+ tslib_1.__decorate([
141
+ (0, typeorm_1.ManyToOne)(type => ChatMessage, { nullable: true, onDelete: 'SET NULL' }),
142
+ tslib_1.__metadata("design:type", ChatMessage)
143
+ ], ChatMessage.prototype, "parentMessage", void 0);
144
+ tslib_1.__decorate([
145
+ (0, typeorm_1.RelationId)((message) => message.parentMessage),
146
+ (0, type_graphql_1.Field)({
147
+ nullable: true,
148
+ description: 'Id of the message this one is in-response-to / continues. Enables linear / branching / tree structures.'
149
+ }),
150
+ tslib_1.__metadata("design:type", String)
151
+ ], ChatMessage.prototype, "parentMessageId", void 0);
152
+ tslib_1.__decorate([
153
+ (0, typeorm_1.CreateDateColumn)(),
154
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Creation timestamp.' }),
155
+ tslib_1.__metadata("design:type", Date)
156
+ ], ChatMessage.prototype, "createdAt", void 0);
157
+ tslib_1.__decorate([
158
+ (0, typeorm_1.UpdateDateColumn)(),
159
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Last update timestamp (relevant once message editing is supported).' }),
160
+ tslib_1.__metadata("design:type", Date)
161
+ ], ChatMessage.prototype, "updatedAt", void 0);
162
+ tslib_1.__decorate([
163
+ (0, typeorm_1.DeleteDateColumn)(),
164
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Soft delete timestamp.' }),
165
+ tslib_1.__metadata("design:type", Date)
166
+ ], ChatMessage.prototype, "deletedAt", void 0);
167
+ exports.ChatMessage = ChatMessage = tslib_1.__decorate([
168
+ (0, typeorm_1.Entity)(),
169
+ (0, typeorm_1.Index)('ix_chat_message_1', (message) => [message.session, message.createdAt]),
170
+ (0, type_graphql_1.ObjectType)({ description: 'A single chat message in a ChatSession.' })
171
+ ], ChatMessage);
172
+ //# sourceMappingURL=chat-message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-message.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatMessage — ChatSession 의 한 메시지.\n *\n * role: 'user' | 'assistant' | 'system'\n * 'system' 은 사용자 직접 편집/import 진행 등 시스템이 자동 생성하는 컨텍스트 메시지.\n *\n * relatedPatchId / relatedImportSessionId 로 메시지가 트리거한 패치·임포트와 연결.\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\nimport { GraphQLJSON } from 'graphql-scalars'\n\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatSession } from '../chat-session/chat-session.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n@Index('ix_chat_message_1', (message: ChatMessage) => [message.session, message.createdAt])\n@ObjectType({ description: 'A single chat message in a ChatSession.' })\nexport class ChatMessage {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => ChatSession, session => session.messages, { onDelete: 'CASCADE' })\n session?: ChatSession\n\n @RelationId((message: ChatMessage) => message.session)\n sessionId?: string\n\n @Column({ type: 'varchar', length: 16 })\n @Field({ description: \"'user' | 'assistant' | 'system'\" })\n role!: string\n\n @Column({\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({ description: 'Message content' })\n content!: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'PatchEntry.id this message triggered (if any).' })\n relatedPatchId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'ImportSession.id this message triggered (if any).' })\n relatedImportSessionId?: string\n\n /**\n * AI 가 응답을 만드는 동안 호출한 도구 trace — JSON-stringified.\n *\n * 컬럼은 string (PatchEntry.ops 와 동일 패턴), GraphQL 노출은 toolUsagesJson getter\n * 로 parse 된 객체. UI 의 fold-able 박스 (\"AI 가 이런 도구를 사용했습니다\") 가\n * 페이지 reload / loadHistory 후에도 살아있도록 영속.\n *\n * 형태: ToolUsage[] (assistant.ts 의 시간순 trace, summarizeToolResult 로 압축).\n * null 이면 도구 호출 없었던 메시지 (text-only 응답).\n */\n @Column({\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 nullable: true\n })\n toolUsages?: string\n\n /** GraphQL 노출용 — JSON 배열로 parse. */\n @Field(() => GraphQLJSON, {\n nullable: true,\n description:\n 'Tool usage trace — array of {name, arguments, result, kind}. Only on assistant messages.'\n })\n get toolUsagesJson(): any {\n if (!this.toolUsages) return null\n try {\n return JSON.parse(this.toolUsages)\n } catch {\n return null\n }\n }\n\n /**\n * 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.\n *\n * **왜 영속하는가**: 도크의 협의는 여러 사람이 며칠에 걸쳐 읽는다. 경고가 그 자리에서만\n * 보이고 새로 고치면 사라지면, 나중에 읽는 사람은 검증되지 않은 문장을 검증된 것으로 읽는다.\n * 다시 계산할 수도 없다 — 근거였던 도구 결과 원문은 남지 않고 압축본만 남기 때문이다.\n *\n * 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).\n */\n @Column({ type: 'varchar', length: 1024, nullable: true })\n groundingWarnings?: string\n\n /** GraphQL 노출용 — 식별자 배열로 parse. */\n @Field(() => GraphQLJSON, {\n nullable: true,\n description: 'Ungrounded identifiers mentioned by this reply (hallucination candidates). Null when grounded.'\n })\n get groundingWarningsJson(): any {\n if (!this.groundingWarnings) return null\n try {\n return JSON.parse(this.groundingWarnings)\n } catch {\n return null\n }\n }\n\n /**\n * 메시지를 일으킨 사용자.\n * - role='user' → 보낸 사용자\n * - role='assistant' → 그 응답을 트리거한 사용자 (직전 user 메시지의 작성자)\n * - role='system' → 시스템 액션을 일으킨 사용자 (예: 직접 편집한 사용자)\n *\n * nullable — legacy 메시지 (이 필드 추가 이전에 작성) 는 null. 또한 ad-hoc 모드 / 시스템\n * 자동 메시지 중 사용자 컨텍스트가 없는 경우도 null 가능.\n */\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'User who triggered this message.' })\n creator?: User\n\n @RelationId((message: ChatMessage) => message.creator)\n creatorId?: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Last user to update this message (for future edit support).' })\n updater?: User\n\n @RelationId((message: ChatMessage) => message.updater)\n updaterId?: string\n\n /**\n * 부모 메시지 — 이 메시지가 \"응답하는 대상\" / \"이어가는 메시지\" 의 id.\n *\n * 의미 (role 별):\n * - 'user' → 직전 in-session 메시지 (보통 assistant 또는 system). 첫 turn 은 null.\n * - 'assistant' → 이 응답이 답하는 user 메시지. (regenerate 로 같은 user 메시지를 부모로\n * 하는 형제 assistant 메시지가 생기면 자연스럽게 branching tree 가 됨)\n * - 'system' → 직전 in-session 메시지 (timeline 연속성 유지).\n *\n * 이 한 컬럼으로 향후 어떤 흐름이든 표현 가능 — linear chain / branching (regenerate) /\n * tree (replies-to-specific-message) 등. UI/쿼리는 점진 도입.\n *\n * onDelete: 'SET NULL' — 부모 메시지가 삭제돼도 child 는 남되 dangling 마킹.\n */\n @ManyToOne(type => ChatMessage, { nullable: true, onDelete: 'SET NULL' })\n parentMessage?: ChatMessage\n\n @RelationId((message: ChatMessage) => message.parentMessage)\n @Field({\n nullable: true,\n description:\n 'Id of the message this one is in-response-to / continues. Enables linear / branching / tree structures.'\n })\n parentMessageId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Creation timestamp.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Last update timestamp (relevant once message editing is supported).' })\n updatedAt?: Date\n\n @DeleteDateColumn()\n @Field({ nullable: true, description: 'Soft delete timestamp.' })\n deletedAt?: Date\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import type { HistoryRow } from './llm-history.js';
2
+ import { type StoredSummary } from './history-summary.js';
3
+ export interface FoldHistoryInput {
4
+ /** 시간 오름차순 이력. 기본은 **전체**이고, `rowsStartAfterSummary` 면 요약 뒤부터의 구간이다. */
5
+ rows: HistoryRow[];
6
+ /** 세션에 저장된 요약 상태. */
7
+ stored?: StoredSummary;
8
+ /**
9
+ * `rows` 가 **이미 요약 뒤부터** 잘려 온 구간인가.
10
+ *
11
+ * ── 왜 이 플래그가 필요한가 ─────────────────────────────────────────────────
12
+ * 호출부가 DB 에서 워터마크(`stored.upToMessageId`) 뒤부터만 읽으면(대화가 길어져도 상수 시간),
13
+ * 여기서 `pendingForSummary` 로 **또 자를 것이 없다.** 그런데 그 함수는 「id 를 못 찾으면 전부
14
+ * 미반영으로 본다」는 안전 규칙을 갖고 있어서, 창으로 읽은 경우에도 우연히 맞는 답을 낸다.
15
+ *
16
+ * 우연에 기대지 않는다. 그 규칙은 **행이 지워졌을 때의 안전망**이고, 여기서 필요한 것은 「이미
17
+ * 잘랐다」는 사실이다. 둘을 같은 분기로 두면, 나중에 그 안전망을 고치는 사람이 이 경로를 조용히
18
+ * 망가뜨린다(요약이 앞부분을 두 번 담거나 빠뜨린다).
19
+ */
20
+ rowsStartAfterSummary?: boolean;
21
+ /** 프롬프트 상한(이 밖으로 밀려난 것이 요약 대상). */
22
+ maxTurns: number;
23
+ /** 새로 밀려난 것이 이 수 이상일 때만 요약을 만든다(매 턴 호출 방지). */
24
+ threshold: number;
25
+ /** 요약 생성 — 실패하면 throw 하거나 빈 문자열을 돌려주면 된다(둘 다 "접지 않음" 으로 처리). */
26
+ summarize: (content: string) => Promise<string | undefined | null>;
27
+ }
28
+ export interface FoldHistoryResult {
29
+ /** 세션에 저장할 값. null 이면 이번엔 접지 않는다(대상 없음·임계치 미만·생성 실패). */
30
+ patch: {
31
+ lastSummary: string;
32
+ summaryUpToMessageId?: string;
33
+ } | null;
34
+ /** 접지 않은 이유 — 관측·테스트용(조용한 무동작을 남기지 않는다). */
35
+ skipped?: 'nothing-dropped' | 'below-threshold' | 'empty-summary' | 'failed';
36
+ }
37
+ /**
38
+ * 접을지 판정하고, 접어야 하면 요약을 만들어 저장 값을 돌려준다.
39
+ *
40
+ * 실패를 삼키되 **왜 안 접었는지**는 남긴다 — 조용한 무동작은 나중에 "요약이 왜 안 생기지" 를
41
+ * 추적 불가능하게 만든다.
42
+ */
43
+ export declare function foldHistorySummary(input: FoldHistoryInput): Promise<FoldHistoryResult>;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.foldHistorySummary = foldHistorySummary;
4
+ const history_summary_js_1 = require("./history-summary.js");
5
+ /**
6
+ * 접을지 판정하고, 접어야 하면 요약을 만들어 저장 값을 돌려준다.
7
+ *
8
+ * 실패를 삼키되 **왜 안 접었는지**는 남긴다 — 조용한 무동작은 나중에 "요약이 왜 안 생기지" 를
9
+ * 추적 불가능하게 만든다.
10
+ */
11
+ async function foldHistorySummary(input) {
12
+ const { dropped } = (0, history_summary_js_1.splitByCap)(input.rows, input.maxTurns);
13
+ if (dropped.length === 0)
14
+ return { patch: null, skipped: 'nothing-dropped' };
15
+ /*
16
+ * 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 요약이 덮은 앞부분은 애초에 실려 오지 않았다.
17
+ * 전체를 읽어 왔을 때만 워터마크로 자른다.
18
+ */
19
+ const pending = input.rowsStartAfterSummary ? dropped : (0, history_summary_js_1.pendingForSummary)(dropped, input.stored);
20
+ if (!(0, history_summary_js_1.shouldResummarize)(pending, input.threshold))
21
+ return { patch: null, skipped: 'below-threshold' };
22
+ let text;
23
+ try {
24
+ text = await input.summarize((0, history_summary_js_1.summaryUserContent)(input.stored?.text, pending));
25
+ }
26
+ catch {
27
+ return { patch: null, skipped: 'failed' };
28
+ }
29
+ if (!text || !text.trim())
30
+ return { patch: null, skipped: 'empty-summary' };
31
+ return { patch: (0, history_summary_js_1.summaryPatch)(text, pending) };
32
+ }
33
+ //# sourceMappingURL=fold-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fold-history.js","sourceRoot":"","sources":["../../../server/service/chat-message/fold-history.ts"],"names":[],"mappings":";;AAwDA,gDAoBC;AApED,6DAO6B;AAmC7B;;;;;GAKG;AACI,KAAK,UAAU,kBAAkB,CAAC,KAAuB;IAC9D,MAAM,EAAE,OAAO,EAAE,GAAG,IAAA,+BAAU,EAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;IAC1D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAE5E;;;OAGG;IACH,MAAM,OAAO,GAAG,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAA,sCAAiB,EAAC,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;IAChG,IAAI,CAAC,IAAA,sCAAiB,EAAC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAEpG,IAAI,IAA+B,CAAA;IACnC,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,KAAK,CAAC,SAAS,CAAC,IAAA,uCAAkB,EAAC,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAA;IAC/E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAA;IAC3C,CAAC;IACD,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,CAAA;IAE3E,OAAO,EAAE,KAAK,EAAE,IAAA,iCAAY,EAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAA;AAC/C,CAAC","sourcesContent":["/*\n * 요약 접기 오케스트레이션 — **판정과 조립은 여기, DB 는 호출부**.\n *\n * 왜 분리하는가: 요약을 만드는 결정(무엇을·언제·무엇에 이어)은 규칙이고, 규칙은 테스트로 고정할 수\n * 있어야 한다. 리졸버 안에 두면 DB 없이 검증할 수 없어 \"배선했지만 한 번도 안 돌아본\" 코드가 된다.\n * 요약 생성기(LLM)와 이력·저장은 호출부가 주입한다.\n */\nimport type { HistoryRow } from './llm-history.js'\nimport {\n splitByCap,\n pendingForSummary,\n shouldResummarize,\n summaryUserContent,\n summaryPatch,\n type StoredSummary\n} from './history-summary.js'\n\nexport interface FoldHistoryInput {\n /** 시간 오름차순 이력. 기본은 **전체**이고, `rowsStartAfterSummary` 면 요약 뒤부터의 구간이다. */\n rows: HistoryRow[]\n /** 세션에 저장된 요약 상태. */\n stored?: StoredSummary\n /**\n * `rows` 가 **이미 요약 뒤부터** 잘려 온 구간인가.\n *\n * ── 왜 이 플래그가 필요한가 ─────────────────────────────────────────────────\n * 호출부가 DB 에서 워터마크(`stored.upToMessageId`) 뒤부터만 읽으면(대화가 길어져도 상수 시간),\n * 여기서 `pendingForSummary` 로 **또 자를 것이 없다.** 그런데 그 함수는 「id 를 못 찾으면 전부\n * 미반영으로 본다」는 안전 규칙을 갖고 있어서, 창으로 읽은 경우에도 우연히 맞는 답을 낸다.\n *\n * 우연에 기대지 않는다. 그 규칙은 **행이 지워졌을 때의 안전망**이고, 여기서 필요한 것은 「이미\n * 잘랐다」는 사실이다. 둘을 같은 분기로 두면, 나중에 그 안전망을 고치는 사람이 이 경로를 조용히\n * 망가뜨린다(요약이 앞부분을 두 번 담거나 빠뜨린다).\n */\n rowsStartAfterSummary?: boolean\n /** 프롬프트 상한(이 밖으로 밀려난 것이 요약 대상). */\n maxTurns: number\n /** 새로 밀려난 것이 이 수 이상일 때만 요약을 만든다(매 턴 호출 방지). */\n threshold: number\n /** 요약 생성 — 실패하면 throw 하거나 빈 문자열을 돌려주면 된다(둘 다 \"접지 않음\" 으로 처리). */\n summarize: (content: string) => Promise<string | undefined | null>\n}\n\nexport interface FoldHistoryResult {\n /** 세션에 저장할 값. null 이면 이번엔 접지 않는다(대상 없음·임계치 미만·생성 실패). */\n patch: { lastSummary: string; summaryUpToMessageId?: string } | null\n /** 접지 않은 이유 — 관측·테스트용(조용한 무동작을 남기지 않는다). */\n skipped?: 'nothing-dropped' | 'below-threshold' | 'empty-summary' | 'failed'\n}\n\n/**\n * 접을지 판정하고, 접어야 하면 요약을 만들어 저장 값을 돌려준다.\n *\n * 실패를 삼키되 **왜 안 접었는지**는 남긴다 — 조용한 무동작은 나중에 \"요약이 왜 안 생기지\" 를\n * 추적 불가능하게 만든다.\n */\nexport async function foldHistorySummary(input: FoldHistoryInput): Promise<FoldHistoryResult> {\n const { dropped } = splitByCap(input.rows, input.maxTurns)\n if (dropped.length === 0) return { patch: null, skipped: 'nothing-dropped' }\n\n /*\n * 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 요약이 덮은 앞부분은 애초에 실려 오지 않았다.\n * 전체를 읽어 왔을 때만 워터마크로 자른다.\n */\n const pending = input.rowsStartAfterSummary ? dropped : pendingForSummary(dropped, input.stored)\n if (!shouldResummarize(pending, input.threshold)) return { patch: null, skipped: 'below-threshold' }\n\n let text: string | undefined | null\n try {\n text = await input.summarize(summaryUserContent(input.stored?.text, pending))\n } catch {\n return { patch: null, skipped: 'failed' }\n }\n if (!text || !text.trim()) return { patch: null, skipped: 'empty-summary' }\n\n return { patch: summaryPatch(text, pending) }\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import type { HistoryRow } from './llm-history.js';
2
+ /** 세션에 저장된 요약 상태. */
3
+ export interface StoredSummary {
4
+ /** 요약 본문(사람이 읽어도 되는 몇 문장). 없으면 아직 요약이 없다. */
5
+ text?: string | null;
6
+ /** 이 메시지까지 요약에 반영됐다. 이후 메시지는 아직 요약 밖. */
7
+ upToMessageId?: string | null;
8
+ }
9
+ /** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */
10
+ export declare function splitByCap(rows: HistoryRow[], maxTurns: number): {
11
+ dropped: HistoryRow[];
12
+ kept: HistoryRow[];
13
+ };
14
+ /**
15
+ * 아직 요약에 반영되지 않은, 밀려난 메시지들.
16
+ *
17
+ * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에
18
+ * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시
19
+ * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).
20
+ */
21
+ export declare function pendingForSummary(dropped: HistoryRow[], stored: StoredSummary | undefined): HistoryRow[];
22
+ /**
23
+ * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.
24
+ * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)
25
+ */
26
+ export declare function shouldResummarize(pending: HistoryRow[], threshold?: number): boolean;
27
+ /** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */
28
+ export declare function summarySourceText(pending: HistoryRow[]): string;
29
+ /**
30
+ * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**
31
+ *
32
+ * 협의에서 나중까지 필요한 것은 "무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지" 다. 잡담·인사는
33
+ * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이
34
+ * 이름을 바꾸면 이후 대화의 접지가 깨진다.
35
+ */
36
+ export declare function summaryInstruction(locale?: string): string;
37
+ /** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */
38
+ export declare function summaryUserContent(previous: string | null | undefined, pending: HistoryRow[]): string;
39
+ /** 요약 저장 값 — 본문과 "어디까지 덮었는지". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */
40
+ export declare function summaryPatch(text: string, pending: HistoryRow[]): {
41
+ lastSummary: string;
42
+ summaryUpToMessageId?: string;
43
+ };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitByCap = splitByCap;
4
+ exports.pendingForSummary = pendingForSummary;
5
+ exports.shouldResummarize = shouldResummarize;
6
+ exports.summarySourceText = summarySourceText;
7
+ exports.summaryInstruction = summaryInstruction;
8
+ exports.summaryUserContent = summaryUserContent;
9
+ exports.summaryPatch = summaryPatch;
10
+ /** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */
11
+ function splitByCap(rows, maxTurns) {
12
+ if (!maxTurns || maxTurns <= 0 || rows.length <= maxTurns)
13
+ return { dropped: [], kept: rows };
14
+ return { dropped: rows.slice(0, rows.length - maxTurns), kept: rows.slice(-maxTurns) };
15
+ }
16
+ /**
17
+ * 아직 요약에 반영되지 않은, 밀려난 메시지들.
18
+ *
19
+ * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에
20
+ * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시
21
+ * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).
22
+ */
23
+ function pendingForSummary(dropped, stored) {
24
+ const upTo = stored?.upToMessageId;
25
+ if (!upTo || !stored?.text)
26
+ return dropped;
27
+ const idx = dropped.findIndex(r => r.id === upTo);
28
+ return idx < 0 ? dropped : dropped.slice(idx + 1);
29
+ }
30
+ /**
31
+ * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.
32
+ * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)
33
+ */
34
+ function shouldResummarize(pending, threshold = 10) {
35
+ return pending.length >= Math.max(1, threshold);
36
+ }
37
+ /** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */
38
+ function summarySourceText(pending) {
39
+ return pending
40
+ .map(r => {
41
+ const who = r.role === 'assistant' ? 'AI' : r.role === 'system' ? 'SYSTEM' : r.senderName || 'USER';
42
+ return `${who}: ${(r.content ?? '').trim()}`;
43
+ })
44
+ .filter(line => line.length > 6)
45
+ .join('\n');
46
+ }
47
+ /**
48
+ * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**
49
+ *
50
+ * 협의에서 나중까지 필요한 것은 "무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지" 다. 잡담·인사는
51
+ * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이
52
+ * 이름을 바꾸면 이후 대화의 접지가 깨진다.
53
+ */
54
+ function summaryInstruction(locale) {
55
+ return [
56
+ 'Summarize the earlier part of a shared operations deliberation so it can be carried into later turns.',
57
+ 'Keep ONLY what stays relevant: what was checked and found, what was decided, what is still open, and any action that was executed.',
58
+ 'Drop greetings, small talk, and repetition.',
59
+ 'Preserve identifiers (node / order / instance ids) EXACTLY as written — renaming them breaks grounding in later turns.',
60
+ 'Never add facts that are not in the text. If something is unclear, leave it out.',
61
+ 'At most 8 short bullet points. No preamble, no closing remark.',
62
+ locale ? `Write the summary in the language of locale "${locale}".` : ''
63
+ ]
64
+ .filter(Boolean)
65
+ .join(' ');
66
+ }
67
+ /** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */
68
+ function summaryUserContent(previous, pending) {
69
+ const head = previous?.trim() ? `SUMMARY SO FAR:\n${previous.trim()}\n\n` : '';
70
+ return `${head}NEW MESSAGES TO FOLD IN:\n${summarySourceText(pending)}`;
71
+ }
72
+ /** 요약 저장 값 — 본문과 "어디까지 덮었는지". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */
73
+ function summaryPatch(text, pending) {
74
+ const last = [...pending].reverse().find(r => r.id)?.id;
75
+ return { lastSummary: text.trim().slice(0, 4000), summaryUpToMessageId: last };
76
+ }
77
+ //# sourceMappingURL=history-summary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history-summary.js","sourceRoot":"","sources":["../../../server/service/chat-message/history-summary.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 이력 요약 접기 — 긴 협의의 앞부분을 **요약으로 대체**한다.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 모델은 기억이 없어 매 턴 이력을 다시 받는다. 그래서 상한(maxTurns)을 두었지만, 상한만으로는\n * 넘친 앞부분이 **그냥 사라진다** — 며칠 이어지는 협의에서 앞에서 합의한 내용이 없던 일이 된다.\n * 요약을 대신 실어 보내면 토큰은 상한 안에 머물면서 맥락은 남는다.\n *\n * ── 규율 ────────────────────────────────────────────────────────────────────\n * ① **어디까지 요약했는지 기록한다**(upToMessageId). 그것이 없으면 매 턴 전체를 다시 요약하거나\n * (비용) 이미 요약한 것을 또 이어 붙인다(중복).\n * ② **누적 요약**이다: 이전 요약 + 새로 밀려난 메시지 → 새 요약. 원본 전체를 다시 읽지 않는다.\n * ③ **아껴 만든다**: 새로 밀려난 것이 임계치를 넘을 때만 다시 만든다. 상한을 넘긴 뒤 매 턴\n * 요약을 만들면 대화마다 LLM 호출이 두 번씩 붙는다.\n * ④ **요약은 요약이라고 밝힌다**: 프롬프트에 \"요약(원문 아님)\" 으로 표기해 모델이 그것을 인용\n * 가능한 원문처럼 다루지 않게 한다. 요약에 없는 사실은 사용자에게 다시 확인해야 한다.\n *\n * 이 파일은 순수 로직이다 — LLM 호출·DB 는 리졸버가 한다(테스트 가능성 유지).\n */\nimport type { HistoryRow } from './llm-history.js'\n\n/** 세션에 저장된 요약 상태. */\nexport interface StoredSummary {\n /** 요약 본문(사람이 읽어도 되는 몇 문장). 없으면 아직 요약이 없다. */\n text?: string | null\n /** 이 메시지까지 요약에 반영됐다. 이후 메시지는 아직 요약 밖. */\n upToMessageId?: string | null\n}\n\n/** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */\nexport function splitByCap(rows: HistoryRow[], maxTurns: number): { dropped: HistoryRow[]; kept: HistoryRow[] } {\n if (!maxTurns || maxTurns <= 0 || rows.length <= maxTurns) return { dropped: [], kept: rows }\n return { dropped: rows.slice(0, rows.length - maxTurns), kept: rows.slice(-maxTurns) }\n}\n\n/**\n * 아직 요약에 반영되지 않은, 밀려난 메시지들.\n *\n * 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에\n * 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시\n * 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).\n */\nexport function pendingForSummary(dropped: HistoryRow[], stored: StoredSummary | undefined): HistoryRow[] {\n const upTo = stored?.upToMessageId\n if (!upTo || !stored?.text) return dropped\n const idx = dropped.findIndex(r => r.id === upTo)\n return idx < 0 ? dropped : dropped.slice(idx + 1)\n}\n\n/**\n * 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.\n * (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)\n */\nexport function shouldResummarize(pending: HistoryRow[], threshold = 10): boolean {\n return pending.length >= Math.max(1, threshold)\n}\n\n/** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */\nexport function summarySourceText(pending: HistoryRow[]): string {\n return pending\n .map(r => {\n const who = r.role === 'assistant' ? 'AI' : r.role === 'system' ? 'SYSTEM' : r.senderName || 'USER'\n return `${who}: ${(r.content ?? '').trim()}`\n })\n .filter(line => line.length > 6)\n .join('\\n')\n}\n\n/**\n * 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**\n *\n * 협의에서 나중까지 필요한 것은 \"무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지\" 다. 잡담·인사는\n * 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이\n * 이름을 바꾸면 이후 대화의 접지가 깨진다.\n */\nexport function summaryInstruction(locale?: string): string {\n return [\n 'Summarize the earlier part of a shared operations deliberation so it can be carried into later turns.',\n 'Keep ONLY what stays relevant: what was checked and found, what was decided, what is still open, and any action that was executed.',\n 'Drop greetings, small talk, and repetition.',\n 'Preserve identifiers (node / order / instance ids) EXACTLY as written — renaming them breaks grounding in later turns.',\n 'Never add facts that are not in the text. If something is unclear, leave it out.',\n 'At most 8 short bullet points. No preamble, no closing remark.',\n locale ? `Write the summary in the language of locale \"${locale}\".` : ''\n ]\n .filter(Boolean)\n .join(' ')\n}\n\n/** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */\nexport function summaryUserContent(previous: string | null | undefined, pending: HistoryRow[]): string {\n const head = previous?.trim() ? `SUMMARY SO FAR:\\n${previous.trim()}\\n\\n` : ''\n return `${head}NEW MESSAGES TO FOLD IN:\\n${summarySourceText(pending)}`\n}\n\n/** 요약 저장 값 — 본문과 \"어디까지 덮었는지\". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */\nexport function summaryPatch(text: string, pending: HistoryRow[]): { lastSummary: string; summaryUpToMessageId?: string } {\n const last = [...pending].reverse().find(r => r.id)?.id\n return { lastSummary: text.trim().slice(0, 4000), summaryUpToMessageId: last }\n}\n"]}
@@ -0,0 +1,5 @@
1
+ import { ChatMessage } from './chat-message.js';
2
+ export { ChatMessage };
3
+ export { ChatMessageSubscription, publishChatMessage, CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-subscription.js';
4
+ export type { ChatMessagePayload } from './chat-message-subscription.js';
5
+ export declare const entities: (typeof ChatMessage)[];
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.entities = exports.chatMessageFilter = exports.CHAT_MESSAGE_TOPIC = exports.publishChatMessage = exports.ChatMessageSubscription = exports.ChatMessage = void 0;
4
+ const chat_message_js_1 = require("./chat-message.js");
5
+ Object.defineProperty(exports, "ChatMessage", { enumerable: true, get: function () { return chat_message_js_1.ChatMessage; } });
6
+ var chat_message_subscription_js_1 = require("./chat-message-subscription.js");
7
+ Object.defineProperty(exports, "ChatMessageSubscription", { enumerable: true, get: function () { return chat_message_subscription_js_1.ChatMessageSubscription; } });
8
+ Object.defineProperty(exports, "publishChatMessage", { enumerable: true, get: function () { return chat_message_subscription_js_1.publishChatMessage; } });
9
+ Object.defineProperty(exports, "CHAT_MESSAGE_TOPIC", { enumerable: true, get: function () { return chat_message_subscription_js_1.CHAT_MESSAGE_TOPIC; } });
10
+ Object.defineProperty(exports, "chatMessageFilter", { enumerable: true, get: function () { return chat_message_subscription_js_1.chatMessageFilter; } });
11
+ exports.entities = [chat_message_js_1.ChatMessage];
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/chat-message/index.ts"],"names":[],"mappings":";;;AAAA,uDAA+C;AAEtC,4FAFA,6BAAW,OAEA;AACpB,+EAKuC;AAJrC,uIAAA,uBAAuB,OAAA;AACvB,kIAAA,kBAAkB,OAAA;AAClB,kIAAA,kBAAkB,OAAA;AAClB,iIAAA,iBAAiB,OAAA;AAIN,QAAA,QAAQ,GAAG,CAAC,6BAAW,CAAC,CAAA","sourcesContent":["import { ChatMessage } from './chat-message.js'\n\nexport { ChatMessage }\nexport {\n ChatMessageSubscription,\n publishChatMessage,\n CHAT_MESSAGE_TOPIC,\n chatMessageFilter\n} from './chat-message-subscription.js'\nexport type { ChatMessagePayload } from './chat-message-subscription.js'\n\nexport const entities = [ChatMessage]\n"]}
@@ -0,0 +1,46 @@
1
+ import type { LLMMessage } from '../types.js';
2
+ import { type StoredSummary } from './history-summary.js';
3
+ /** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */
4
+ export interface HistoryRow {
5
+ id?: string;
6
+ role: string;
7
+ /** 멘션 마커(#token{refid:N})는 호출자가 미리 제거해 넘긴다 — 모델 주의 분산·토큰 낭비 방지. */
8
+ content: string;
9
+ /** 발신자 식별자(서버 내부용 — 발신자 수 계산에만 쓰고 프롬프트에 넣지 않는다). */
10
+ senderId?: string;
11
+ /** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */
12
+ senderName?: string;
13
+ }
14
+ export interface BuildLlmHistoryOptions {
15
+ /** 이 메시지까지만 유지하고 이후는 버린다(편집·재생성으로 접은 구간). 미지정이면 전부 사용. */
16
+ truncateAfterMessageId?: string;
17
+ /** 잘라내기와 무관하게 항상 유지할 메시지 id(방금 저장한 새 사용자 메시지 등). */
18
+ keepMessageIds?: string[];
19
+ /** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */
20
+ labelSenders?: boolean;
21
+ /**
22
+ * 모델에 넘길 **최근 메시지 수 상한**. 초과분(오래된 앞부분)은 버리고 생략 표시를 남긴다.
23
+ *
24
+ * 왜 필요한가: 협의는 며칠에 걸친다. 상한이 없으면 매 턴 전체 이력을 보내 프롬프트가 무한히
25
+ * 자라고(비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 미지정이면 전부 보낸다(기존 동작).
26
+ *
27
+ * **생략은 반드시 알린다** — 조용히 버리면 모델은 남은 첫 줄을 대화의 시작으로 오해한다.
28
+ * 이건 상한일 뿐 요약이 아니다. 버린 내용을 요약해 실어 보내는 것은 다음 단계다(ChatSession.lastSummary).
29
+ */
30
+ maxTurns?: number;
31
+ /**
32
+ * 상한으로 밀려난 앞부분을 대신할 **요약**(세션에 저장된 것).
33
+ *
34
+ * 있으면 "N개 생략" 대신 요약을 첫 줄로 싣는다 — 며칠 이어지는 협의에서 앞에서 합의한 것이
35
+ * 없던 일이 되지 않게. 요약이 밀려난 구간을 **전부 덮지 못하면** 덮지 못한 개수도 함께 밝힌다
36
+ * (요약이 최신이 아닐 수 있다는 사실을 숨기지 않는다).
37
+ */
38
+ summary?: StoredSummary;
39
+ }
40
+ /**
41
+ * 저장된 이력 → LLM 대화.
42
+ *
43
+ * 입력은 **시간 오름차순**을 가정한다(호출자가 createdAt ASC 로 조회). 정렬을 다시 하지 않는다 —
44
+ * 같은 시각의 순서까지 보장하려면 조회 쪽에서 결정해야 하고, 여기서 추측하면 두 곳이 어긋난다.
45
+ */
46
+ export declare function buildLlmHistory(rows: HistoryRow[], options?: BuildLlmHistoryOptions): LLMMessage[];
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildLlmHistory = buildLlmHistory;
4
+ const history_summary_js_1 = require("./history-summary.js");
5
+ /** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */
6
+ const SYSTEM_LABEL = '시스템';
7
+ /** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */
8
+ const UNKNOWN_LABEL = '참여자';
9
+ /** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */
10
+ const OMITTED_LABEL = '시스템';
11
+ /**
12
+ * 저장된 이력 → LLM 대화.
13
+ *
14
+ * 입력은 **시간 오름차순**을 가정한다(호출자가 createdAt ASC 로 조회). 정렬을 다시 하지 않는다 —
15
+ * 같은 시각의 순서까지 보장하려면 조회 쪽에서 결정해야 하고, 여기서 추측하면 두 곳이 어긋난다.
16
+ */
17
+ function buildLlmHistory(rows, options = {}) {
18
+ const keep = new Set(options.keepMessageIds ?? []);
19
+ const truncated = truncate(rows, options.truncateAfterMessageId, keep);
20
+ /* 상한 — 최근 것부터 남긴다. 오래된 말보다 최근 상황이 답을 좌우한다. */
21
+ const limit = options.maxTurns && options.maxTurns > 0 ? options.maxTurns : 0;
22
+ const omitted = limit && truncated.length > limit ? truncated.length - limit : 0;
23
+ const kept = omitted ? truncated.slice(-limit) : truncated;
24
+ const label = options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1;
25
+ const out = [];
26
+ if (omitted) {
27
+ const dropped = truncated.slice(0, truncated.length - kept.length);
28
+ const summaryText = (options.summary?.text ?? '').trim();
29
+ if (summaryText) {
30
+ /* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.
31
+ * 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */
32
+ const uncovered = (0, history_summary_js_1.pendingForSummary)(dropped, options.summary).length;
33
+ const tail = uncovered
34
+ ? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`
35
+ : '';
36
+ out.push({
37
+ role: 'user',
38
+ content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\n${summaryText}`
39
+ });
40
+ }
41
+ else {
42
+ /* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */
43
+ out.push({
44
+ role: 'user',
45
+ content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`
46
+ });
47
+ }
48
+ }
49
+ for (const row of kept) {
50
+ const content = (row.content ?? '').trim();
51
+ if (!content)
52
+ continue; // 빈 본문(pending 자리 등)은 모델에 넘길 것이 없다
53
+ if (row.role === 'assistant') {
54
+ out.push({ role: 'assistant', content });
55
+ continue;
56
+ }
57
+ if (row.role === 'system') {
58
+ out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` });
59
+ continue;
60
+ }
61
+ out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content });
62
+ }
63
+ return out;
64
+ }
65
+ /** 사람이 보낸 줄인가 — 발신자 수 계산 대상. system 기록은 사람이 아니다. */
66
+ function isHuman(row) {
67
+ return row.role !== 'assistant' && row.role !== 'system';
68
+ }
69
+ function speaker(row) {
70
+ const name = (row.senderName ?? '').trim();
71
+ return name || UNKNOWN_LABEL;
72
+ }
73
+ /**
74
+ * 접은 구간 제거. `truncateAfterMessageId` 가 없거나 이력에서 못 찾으면 **전부 유지**한다
75
+ * (조용히 다 버리는 것보다 다 보내는 쪽이 안전하다 — 문맥 상실은 답을 망친다).
76
+ */
77
+ function truncate(rows, afterId, keep) {
78
+ if (!afterId)
79
+ return rows;
80
+ const idx = rows.findIndex(r => r.id === afterId);
81
+ if (idx < 0)
82
+ return rows;
83
+ const head = rows.slice(0, idx + 1);
84
+ const tail = rows.slice(idx + 1).filter(r => r.id && keep.has(r.id));
85
+ return [...head, ...tail];
86
+ }
87
+ //# sourceMappingURL=llm-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm-history.js","sourceRoot":"","sources":["../../../server/service/chat-message/llm-history.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 에이전트에게 넘길 대화 이력 조립 — 순수 로직(리졸버와 분리해 단위 테스트 가능).\n *\n * ── 왜 서버 정본인가 ─────────────────────────────────────────────────────────\n * 예전에는 클라이언트가 화면에 들고 있던 줄들을 그대로 LLM 에 넘겼다. 협의(여러 참여자)에서는\n * 참여자마다 화면이 달라 **에이전트가 사람마다 다른 대화를 본다**. 서버는 ChatMessage 에 정본을\n * 갖고 있으므로 그것을 쓴다. 세션이 없는 즉석 대화(ad-hoc)만 클라이언트 이력을 쓴다.\n *\n * ── 발신자 표기 ─────────────────────────────────────────────────────────────\n * LLMMessage 는 `{ role, content }` 뿐이다 — 발신자 칸이 없다(Anthropic·Gemini 는 message.name 을\n * 지원하지 않아 이식성이 없다). 그래서 발신자는 **본문 맨 앞 표기**로만 전달할 수 있다.\n *\n * [김철수] 승인합니다\n *\n * **이 표기는 추론용 문맥이며 권한 판정 근거가 아니다.** 사용자가 본문에 같은 형식을 직접 적을 수\n * 있으므로, 되돌릴 수 없는 조치의 승인 여부는 반드시 서버 게이트(요청자 신원 기반)가 판정한다.\n * 표기는 \"누가 무슨 맥락에서 말했는지\"를 모델이 이해하게 하는 용도로만 쓴다.\n *\n * 발신자가 **한 명뿐이면 표기하지 않는다** — 혼자 쓰는 대화(대다수)에 노이즈와 토큰을 늘리지 않고,\n * 기존 동작과 같은 프롬프트를 유지한다.\n *\n * ── system 행 ───────────────────────────────────────────────────────────────\n * 캔버스 직접 편집 기록(`User directly edited the board: …`)은 원래 에이전트에게 알리려고 남긴 것인데,\n * 클라이언트가 role='system' 을 걸러내 사장돼 있었다. LLMMessage 에 system 역할이 없으므로\n * `[시스템]` 표기를 붙인 user 메시지로 넘긴다 — 사람이 아니므로 발신자 수 계산에는 넣지 않는다.\n *\n * ── 편집·재생성으로 접은 구간 ───────────────────────────────────────────────\n * 클라이언트의 메시지 편집은 화면에서만 접고 **서버 이력은 유지**한다(board-ai-chat.editUserMessage).\n * 서버 정본으로 바꾸면 접은 말이 에이전트 문맥에 되살아난다. 그래서 호출자가 `truncateAfterMessageId`\n * (마지막으로 남긴 메시지 id)를 주면 그 뒤 행을 버린다. 단 `keepMessageIds` 로 지정한 행(방금 저장한\n * 새 사용자 메시지)은 시간상 뒤에 있어도 항상 남긴다.\n *\n * 알려진 한계(현행 동작과 동일): 공유 세션에서 접은 구간에 **다른 사람의 발언**이 있으면 그것도\n * 함께 제외된다. 오늘 클라이언트가 보내는 이력도 같으므로 이 단계에서는 동등성을 유지하고,\n * \"내 발언만 접기\"는 별도 개선으로 다룬다.\n */\nimport type { LLMMessage } from '../types.js'\nimport { pendingForSummary, type StoredSummary } from './history-summary.js'\n\n/** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */\nexport interface HistoryRow {\n id?: string\n role: string\n /** 멘션 마커(#token{refid:N})는 호출자가 미리 제거해 넘긴다 — 모델 주의 분산·토큰 낭비 방지. */\n content: string\n /** 발신자 식별자(서버 내부용 — 발신자 수 계산에만 쓰고 프롬프트에 넣지 않는다). */\n senderId?: string\n /** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */\n senderName?: string\n}\n\nexport interface BuildLlmHistoryOptions {\n /** 이 메시지까지만 유지하고 이후는 버린다(편집·재생성으로 접은 구간). 미지정이면 전부 사용. */\n truncateAfterMessageId?: string\n /** 잘라내기와 무관하게 항상 유지할 메시지 id(방금 저장한 새 사용자 메시지 등). */\n keepMessageIds?: string[]\n /** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */\n labelSenders?: boolean\n /**\n * 모델에 넘길 **최근 메시지 수 상한**. 초과분(오래된 앞부분)은 버리고 생략 표시를 남긴다.\n *\n * 왜 필요한가: 협의는 며칠에 걸친다. 상한이 없으면 매 턴 전체 이력을 보내 프롬프트가 무한히\n * 자라고(비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 미지정이면 전부 보낸다(기존 동작).\n *\n * **생략은 반드시 알린다** — 조용히 버리면 모델은 남은 첫 줄을 대화의 시작으로 오해한다.\n * 이건 상한일 뿐 요약이 아니다. 버린 내용을 요약해 실어 보내는 것은 다음 단계다(ChatSession.lastSummary).\n */\n maxTurns?: number\n /**\n * 상한으로 밀려난 앞부분을 대신할 **요약**(세션에 저장된 것).\n *\n * 있으면 \"N개 생략\" 대신 요약을 첫 줄로 싣는다 — 며칠 이어지는 협의에서 앞에서 합의한 것이\n * 없던 일이 되지 않게. 요약이 밀려난 구간을 **전부 덮지 못하면** 덮지 못한 개수도 함께 밝힌다\n * (요약이 최신이 아닐 수 있다는 사실을 숨기지 않는다).\n */\n summary?: StoredSummary\n}\n\n/** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */\nconst SYSTEM_LABEL = '시스템'\n/** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */\nconst UNKNOWN_LABEL = '참여자'\n/** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */\nconst OMITTED_LABEL = '시스템'\n\n/**\n * 저장된 이력 → LLM 대화.\n *\n * 입력은 **시간 오름차순**을 가정한다(호출자가 createdAt ASC 로 조회). 정렬을 다시 하지 않는다 —\n * 같은 시각의 순서까지 보장하려면 조회 쪽에서 결정해야 하고, 여기서 추측하면 두 곳이 어긋난다.\n */\nexport function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOptions = {}): LLMMessage[] {\n const keep = new Set(options.keepMessageIds ?? [])\n const truncated = truncate(rows, options.truncateAfterMessageId, keep)\n /* 상한 — 최근 것부터 남긴다. 오래된 말보다 최근 상황이 답을 좌우한다. */\n const limit = options.maxTurns && options.maxTurns > 0 ? options.maxTurns : 0\n const omitted = limit && truncated.length > limit ? truncated.length - limit : 0\n const kept = omitted ? truncated.slice(-limit) : truncated\n\n const label =\n options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1\n\n const out: LLMMessage[] = []\n if (omitted) {\n const dropped = truncated.slice(0, truncated.length - kept.length)\n const summaryText = (options.summary?.text ?? '').trim()\n if (summaryText) {\n /* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.\n * 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */\n const uncovered = pendingForSummary(dropped, options.summary).length\n const tail = uncovered\n ? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`\n : ''\n out.push({\n role: 'user',\n content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\\n${summaryText}`\n })\n } else {\n /* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */\n out.push({\n role: 'user',\n content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`\n })\n }\n }\n for (const row of kept) {\n const content = (row.content ?? '').trim()\n if (!content) continue // 빈 본문(pending 자리 등)은 모델에 넘길 것이 없다\n if (row.role === 'assistant') {\n out.push({ role: 'assistant', content })\n continue\n }\n if (row.role === 'system') {\n out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` })\n continue\n }\n out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content })\n }\n return out\n}\n\n/** 사람이 보낸 줄인가 — 발신자 수 계산 대상. system 기록은 사람이 아니다. */\nfunction isHuman(row: HistoryRow): boolean {\n return row.role !== 'assistant' && row.role !== 'system'\n}\n\nfunction speaker(row: HistoryRow): string {\n const name = (row.senderName ?? '').trim()\n return name || UNKNOWN_LABEL\n}\n\n/**\n * 접은 구간 제거. `truncateAfterMessageId` 가 없거나 이력에서 못 찾으면 **전부 유지**한다\n * (조용히 다 버리는 것보다 다 보내는 쪽이 안전하다 — 문맥 상실은 답을 망친다).\n */\nfunction truncate(rows: HistoryRow[], afterId: string | undefined, keep: Set<string>): HistoryRow[] {\n if (!afterId) return rows\n const idx = rows.findIndex(r => r.id === afterId)\n if (idx < 0) return rows\n const head = rows.slice(0, idx + 1)\n const tail = rows.slice(idx + 1).filter(r => r.id && keep.has(r.id))\n return [...head, ...tail]\n}\n"]}