@things-factory/ai-assistant 10.1.79 → 10.1.87

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 (89) hide show
  1. package/client/components/assistant-chat.ts +102 -13
  2. package/client/components/assistant-dock.ts +206 -18
  3. package/client/components/assistant-mode.test.ts +16 -2
  4. package/client/components/assistant-session-manager.ts +58 -26
  5. package/client/components/assistant-session-toolbar.ts +4 -2
  6. package/client/components/chat-echo-dedup.ts +4 -0
  7. package/client/components/proposal-outcome-line.ts +36 -0
  8. package/client/components/proposal-request.test.ts +52 -0
  9. package/client/utils/assistant-navigation.test.ts +38 -0
  10. package/client/utils/assistant-navigation.ts +6 -0
  11. package/client/utils/assistant-session-controller.ts +2 -0
  12. package/client/utils/assistant-session-transport.ts +2 -2
  13. package/client/utils/assistant-surface.ts +21 -0
  14. package/dist-client/components/assistant-chat.d.ts +15 -0
  15. package/dist-client/components/assistant-chat.js +106 -13
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.d.ts +16 -0
  18. package/dist-client/components/assistant-dock.js +221 -20
  19. package/dist-client/components/assistant-dock.js.map +1 -1
  20. package/dist-client/components/assistant-mode.test.js +14 -2
  21. package/dist-client/components/assistant-mode.test.js.map +1 -1
  22. package/dist-client/components/assistant-session-manager.d.ts +4 -0
  23. package/dist-client/components/assistant-session-manager.js +74 -27
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  26. package/dist-client/components/assistant-session-toolbar.js +8 -2
  27. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  28. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  29. package/dist-client/components/chat-echo-dedup.js +2 -0
  30. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  31. package/dist-client/components/proposal-outcome-line.d.ts +29 -0
  32. package/dist-client/components/proposal-outcome-line.js +15 -0
  33. package/dist-client/components/proposal-outcome-line.js.map +1 -1
  34. package/dist-client/components/proposal-request.test.d.ts +1 -0
  35. package/dist-client/components/proposal-request.test.js +45 -0
  36. package/dist-client/components/proposal-request.test.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-client/utils/assistant-navigation.d.ts +6 -0
  39. package/dist-client/utils/assistant-navigation.js +9 -0
  40. package/dist-client/utils/assistant-navigation.js.map +1 -0
  41. package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
  42. package/dist-client/utils/assistant-navigation.test.js +37 -0
  43. package/dist-client/utils/assistant-navigation.test.js.map +1 -0
  44. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  45. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  46. package/dist-client/utils/assistant-session-transport.js +2 -2
  47. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  48. package/dist-client/utils/assistant-surface.d.ts +19 -0
  49. package/dist-client/utils/assistant-surface.js.map +1 -1
  50. package/dist-server/service/assistant-chat-resolver.js +1 -0
  51. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  52. package/dist-server/service/chat-message/chat-message.d.ts +10 -0
  53. package/dist-server/service/chat-message/chat-message.js +29 -0
  54. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  55. package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
  56. package/dist-server/service/chat-message/proposal-outcome.js +35 -1
  57. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
  58. package/dist-server/service/chat-session/chat-session.d.ts +5 -5
  59. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  60. package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
  61. package/dist-server/service/chat-session/session-inbox.js +7 -0
  62. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  63. package/dist-server/service/chat-session/session-visibility.d.ts +8 -5
  64. package/dist-server/service/chat-session/session-visibility.js +9 -15
  65. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  66. package/dist-server/service/chat-session-resolver.d.ts +1 -0
  67. package/dist-server/service/chat-session-resolver.js +64 -10
  68. package/dist-server/service/chat-session-resolver.js.map +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/server/service/assistant-chat-resolver.ts +1 -0
  72. package/server/service/chat-message/chat-message.ts +32 -0
  73. package/server/service/chat-message/proposal-outcome.test.ts +53 -2
  74. package/server/service/chat-message/proposal-outcome.ts +33 -0
  75. package/server/service/chat-session/chat-session.ts +5 -5
  76. package/server/service/chat-session/session-inbox.ts +7 -0
  77. package/server/service/chat-session/session-visibility.test.ts +6 -6
  78. package/server/service/chat-session/session-visibility.ts +9 -15
  79. package/server/service/chat-session-resolver.ts +72 -13
  80. package/server/service/session-anchor.test.ts +2 -2
  81. package/test/assistant-dock.test.ts +8 -3
  82. package/test/assistant-session-toolbar.test.ts +7 -1
  83. package/test/open-doors.test.ts +2 -1
  84. package/test/session-discovery.test.ts +42 -0
  85. package/translations/en.json +28 -2
  86. package/translations/ja.json +28 -2
  87. package/translations/ko.json +28 -2
  88. package/translations/ms.json +28 -2
  89. package/translations/zh.json +28 -2
@@ -1 +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 /**\n * 이 말이 누구에게 한 말인가 — `'assistant'` · `'room'`(ADR-0070 A15 결정 ①).\n *\n * **값은 그 행을 쓴 문이 찍는다.** 어시스턴트에게 묻는 문(`assistantChat`)이 저장한 사람의 행은\n * `assistant` 이고, 사람끼리의 문(`appendSessionTurns`)이 저장한 행은 `room` 이다. 클라이언트가\n * 인자로 보내지 않는다 — 인자로 받으면 「어느 문을 불렀나」와 「무엇을 적어 보냈나」 두 사실이\n * 생기고, 둘은 어긋날 수 있다.\n *\n * 사람의 행에만 뜻이 있다. 어시스턴트의 답은 `parentMessage` 로 자기를 부른 턴에 귀속되고,\n * `system` 행은 누구에게 한 말이 아니다.\n */\n @Column({ type: 'varchar', length: 16, nullable: true })\n @Field({ nullable: true, description: \"Who this turn was for: 'assistant' | 'room'. Written by the door that stored it, never sent by the client.\" })\n addressee?: string\n\n /**\n * 이 행의 값이 지나온 문들 — ADR-0073 판정 1.\n *\n * 어시스턴트의 답과 제안 행에만 채워진다. 사람의 말은 빈 목록이다 — 사람이 자기 입으로 한 말에는\n * 문이 없다. 답을 저장할 때 그 턴에 부른 도구들의 `doors`(ADR-0072)를 합쳐 옮겨 적는다.\n *\n * 읽을 때마다 **읽는 사람**이 이 문을 전부 지나는지 보고, 못 지나면 본문 대신 한 줄이 나간다. 그래서\n * 이 칸은 \"누가 볼 수 있나\"를 적은 것이고, 저장된 값 자체는 누구에게도 달라지지 않는다.\n *\n * 목록이 비면 누구나 읽는다. 등록기가 모르는 도구가 답에 끼면 `unresolved:<도구>` 가 적히고 아무도\n * 못 지난다 — 모르는 값은 가려지는 쪽으로 넘어뜨린다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], {\n nullable: true,\n description:\n 'GraphQL doors the tools behind this answer went through. Empty for what people typed. A reader who does not pass all of them gets a one-line notice in place of the content.'\n })\n doors?: string[]\n\n /**\n * 이 행이 **이 사람에게** 가려져 나갔는가 — 저장하지 않는다.\n *\n * 같은 행이 사람마다 다르게 나가므로 칸에 적을 값이 없다. 읽어 나갈 때 채운다. 화면은 이것으로\n * 자물쇠를 그리고, 본문에 온 한 줄을 어시스턴트의 말로 오해하지 않는다.\n */\n @Field({ nullable: true, description: 'True when the content was replaced because this reader does not pass the row doors.' })\n redacted?: boolean\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 /**\n * The files sent with this turn - `[{ id, name, mimetype, size }]` as JSON (design/ai/shared-dock.md §9-2).\n * The files themselves live in attachment-base under the conversation and are opened only through it.\n */\n @Column({ type: 'text', nullable: true })\n attachments?: string\n\n @Field(() => GraphQLJSON, { nullable: true, description: 'Files sent with this turn: [{ id, name, mimetype, size }].' })\n get attachmentsJson(): any {\n if (!this.attachments) return null\n try {\n return JSON.parse(this.attachments)\n } catch {\n return null\n }\n }\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"]}
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 /**\n * 이 말이 누구에게 한 말인가 — `'assistant'` · `'room'`(ADR-0070 A15 결정 ①).\n *\n * **값은 그 행을 쓴 문이 찍는다.** 어시스턴트에게 묻는 문(`assistantChat`)이 저장한 사람의 행은\n * `assistant` 이고, 사람끼리의 문(`appendSessionTurns`)이 저장한 행은 `room` 이다. 클라이언트가\n * 인자로 보내지 않는다 — 인자로 받으면 「어느 문을 불렀나」와 「무엇을 적어 보냈나」 두 사실이\n * 생기고, 둘은 어긋날 수 있다.\n *\n * 사람의 행에만 뜻이 있다. 어시스턴트의 답은 `parentMessage` 로 자기를 부른 턴에 귀속되고,\n * `system` 행은 누구에게 한 말이 아니다.\n */\n @Column({ type: 'varchar', length: 16, nullable: true })\n @Field({ nullable: true, description: \"Who this turn was for: 'assistant' | 'room'. Written by the door that stored it, never sent by the client.\" })\n addressee?: string\n\n /**\n * 이 행의 값이 지나온 문들 — ADR-0073 판정 1.\n *\n * 어시스턴트의 답과 제안 행에만 채워진다. 사람의 말은 빈 목록이다 — 사람이 자기 입으로 한 말에는\n * 문이 없다. 답을 저장할 때 그 턴에 부른 도구들의 `doors`(ADR-0072)를 합쳐 옮겨 적는다.\n *\n * 읽을 때마다 **읽는 사람**이 이 문을 전부 지나는지 보고, 못 지나면 본문 대신 한 줄이 나간다. 그래서\n * 이 칸은 \"누가 볼 수 있나\"를 적은 것이고, 저장된 값 자체는 누구에게도 달라지지 않는다.\n *\n * 목록이 비면 누구나 읽는다. 등록기가 모르는 도구가 답에 끼면 `unresolved:<도구>` 가 적히고 아무도\n * 못 지난다 — 모르는 값은 가려지는 쪽으로 넘어뜨린다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], {\n nullable: true,\n description:\n 'GraphQL doors the tools behind this answer went through. Empty for what people typed. A reader who does not pass all of them gets a one-line notice in place of the content.'\n })\n doors?: string[]\n\n /**\n * 이 행이 **이 사람에게** 가려져 나갔는가 — 저장하지 않는다.\n *\n * 같은 행이 사람마다 다르게 나가므로 칸에 적을 값이 없다. 읽어 나갈 때 채운다. 화면은 이것으로\n * 자물쇠를 그리고, 본문에 온 한 줄을 어시스턴트의 말로 오해하지 않는다.\n */\n @Field({ nullable: true, description: 'True when the content was replaced because this reader does not pass the row doors.' })\n redacted?: boolean\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 * The proposals this answer made - JSON-stringified, like `toolUsages` (ADR-0097 Q-D4 ④).\n *\n * A card lived only in the mutation's answer, so a refresh took it away, and with it the one place the\n * person could see that a proposal was requested and never answered. Stored, the card comes back with\n * its state read from the conversation's rows. Cleared with the body when the row is redacted\n * (`message-doors.ts` VALUE_CARRYING) - a proposal carries the same values as the answer.\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 proposals?: string\n\n @Field(() => GraphQLJSON, { nullable: true, description: 'Proposals this answer made - the cards the panel draws. Only on assistant messages.' })\n get proposalsJson(): any {\n if (!this.proposals) return null\n try {\n return JSON.parse(this.proposals)\n } catch {\n return null\n }\n }\n\n /**\n * 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.\n *\n * **왜 영속하는가**: 도크의 협의는 여러 사람이 며칠에 걸쳐 읽는다. 경고가 그 자리에서만\n * 보이고 새로 고치면 사라지면, 나중에 읽는 사람은 검증되지 않은 문장을 검증된 것으로 읽는다.\n * 다시 계산할 수도 없다 — 근거였던 도구 결과 원문은 남지 않고 압축본만 남기 때문이다.\n *\n * 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).\n */\n /**\n * The files sent with this turn - `[{ id, name, mimetype, size }]` as JSON (design/ai/shared-dock.md §9-2).\n * The files themselves live in attachment-base under the conversation and are opened only through it.\n */\n @Column({ type: 'text', nullable: true })\n attachments?: string\n\n @Field(() => GraphQLJSON, { nullable: true, description: 'Files sent with this turn: [{ id, name, mimetype, size }].' })\n get attachmentsJson(): any {\n if (!this.attachments) return null\n try {\n return JSON.parse(this.attachments)\n } catch {\n return null\n }\n }\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"]}
@@ -16,3 +16,11 @@ export declare const PROPOSAL_VERDICTS: ProposalVerdict[];
16
16
  * English, like the other lines the model reads. The panel turns it into the reader's language.
17
17
  */
18
18
  export declare function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string;
19
+ export declare const PROPOSAL_REQUESTED = "requested";
20
+ /** `proposal <key>: requested` */
21
+ export declare function formatProposalRequestedLine(key: string): string;
22
+ /**
23
+ * Whether this proposal was already taken in this conversation — requested, or answered.
24
+ * The caller passes the conversation's system lines; the answer is the rule, kept testable.
25
+ */
26
+ export declare function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean;
@@ -12,8 +12,10 @@
12
12
  * panel's parser is tested against this formatter.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.PROPOSAL_VERDICTS = exports.PROPOSAL_OUTCOME_PREFIX = void 0;
15
+ exports.PROPOSAL_REQUESTED = exports.PROPOSAL_VERDICTS = exports.PROPOSAL_OUTCOME_PREFIX = void 0;
16
16
  exports.formatProposalOutcomeLine = formatProposalOutcomeLine;
17
+ exports.formatProposalRequestedLine = formatProposalRequestedLine;
18
+ exports.proposalAlreadyTaken = proposalAlreadyTaken;
17
19
  exports.PROPOSAL_OUTCOME_PREFIX = 'proposal ';
18
20
  exports.PROPOSAL_VERDICTS = ['accepted', 'refused', 'unavailable', 'declined'];
19
21
  /**
@@ -28,4 +30,36 @@ function formatProposalOutcomeLine(key, outcome) {
28
30
  : '';
29
31
  return `${exports.PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`;
30
32
  }
33
+ /*
34
+ * "Requested" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).
35
+ *
36
+ * The card used to remember that it was pressed in the page's memory only. After a refresh a
37
+ * proposal whose outcome never came back had its button again, and pressing it asked the host to
38
+ * apply it a second time. The request is now a row, like the outcome, so every reader sees it: the
39
+ * card after a refresh, another window, and the model ("requested, no answer yet").
40
+ *
41
+ * It is not a verdict — nothing happened yet. It only says the host was asked.
42
+ */
43
+ exports.PROPOSAL_REQUESTED = 'requested';
44
+ /** `proposal <key>: requested` */
45
+ function formatProposalRequestedLine(key) {
46
+ return `${exports.PROPOSAL_OUTCOME_PREFIX}${key}: ${exports.PROPOSAL_REQUESTED}`;
47
+ }
48
+ /**
49
+ * Whether this proposal was already taken in this conversation — requested, or answered.
50
+ * The caller passes the conversation's system lines; the answer is the rule, kept testable.
51
+ */
52
+ function proposalAlreadyTaken(key, systemLines) {
53
+ const requested = formatProposalRequestedLine(key);
54
+ const answered = `${exports.PROPOSAL_OUTCOME_PREFIX}${key}: `;
55
+ return systemLines.some(line => {
56
+ const text = (line ?? '').trim();
57
+ if (text === requested)
58
+ return true;
59
+ if (!text.startsWith(answered))
60
+ return false;
61
+ const rest = text.slice(answered.length);
62
+ return exports.PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `));
63
+ });
64
+ }
31
65
  //# sourceMappingURL=proposal-outcome.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"proposal-outcome.js","sourceRoot":"","sources":["../../../server/service/chat-message/proposal-outcome.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAuBH,8DAOC;AAhBY,QAAA,uBAAuB,GAAG,WAAW,CAAA;AAErC,QAAA,iBAAiB,GAAsB,CAAC,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,CAAC,CAAA;AAEtG;;;;GAIG;AACH,SAAgB,yBAAyB,CAAC,GAAW,EAAE,OAAwB;IAC7E,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;IACjE,MAAM,OAAO,GACX,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ,IAAI,OAAO,OAAO,CAAC,MAAM,KAAK,QAAQ;QACvE,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,EAAE;QAC9E,CAAC,CAAC,EAAE,CAAA;IACR,OAAO,GAAG,+BAAuB,GAAG,GAAG,KAAK,OAAO,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,EAAE,CAAA;AAClF,CAAC","sourcesContent":["/*\n * What the host did with a proposal, written into the conversation (ADR-0070 A12-a).\n *\n * The card used to change to \"executed\" the moment it was pressed, which is the one thing it knew\n * and the one thing that is not the answer. The host's answer comes back through a server door and\n * is stored as a `system` row, so three readers get it from the same place: the card that is open,\n * the card after a refresh, and the model on its next turn — a model that is not told its proposal\n * was refused goes on as though it had been applied.\n *\n * The line is the row's content. It is parsed back by the panel, so the shape is fixed here and the\n * panel's parser is tested against this formatter.\n */\n\n/** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */\nexport type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'\n\nexport interface ProposalOutcome {\n verdict: ProposalVerdict\n /** The host's own reason code, carried through untouched — no mapping table (ADR-0042). */\n errorCode?: string\n /** For a proposal made of several edits: how many went in, and how many did not. */\n applied?: number\n missed?: number\n}\n\nexport const PROPOSAL_OUTCOME_PREFIX = 'proposal '\n\nexport const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']\n\n/**\n * `proposal <key>: <verdict> (<errorCode>) 2/3`\n *\n * English, like the other lines the model reads. The panel turns it into the reader's language.\n */\nexport function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string {\n const reason = outcome.errorCode ? ` (${outcome.errorCode})` : ''\n const counted =\n typeof outcome.applied === 'number' || typeof outcome.missed === 'number'\n ? ` ${outcome.applied ?? 0}/${(outcome.applied ?? 0) + (outcome.missed ?? 0)}`\n : ''\n return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`\n}\n"]}
1
+ {"version":3,"file":"proposal-outcome.js","sourceRoot":"","sources":["../../../server/service/chat-message/proposal-outcome.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAuBH,8DAOC;AAeD,kEAEC;AAMD,oDAUC;AAjDY,QAAA,uBAAuB,GAAG,WAAW,CAAA;AAErC,QAAA,iBAAiB,GAAsB,CAAC,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,CAAC,CAAA;AAEtG;;;;GAIG;AACH,SAAgB,yBAAyB,CAAC,GAAW,EAAE,OAAwB;IAC7E,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;IACjE,MAAM,OAAO,GACX,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ,IAAI,OAAO,OAAO,CAAC,MAAM,KAAK,QAAQ;QACvE,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,EAAE;QAC9E,CAAC,CAAC,EAAE,CAAA;IACR,OAAO,GAAG,+BAAuB,GAAG,GAAG,KAAK,OAAO,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,EAAE,CAAA;AAClF,CAAC;AAED;;;;;;;;;GASG;AACU,QAAA,kBAAkB,GAAG,WAAW,CAAA;AAE7C,kCAAkC;AAClC,SAAgB,2BAA2B,CAAC,GAAW;IACrD,OAAO,GAAG,+BAAuB,GAAG,GAAG,KAAK,0BAAkB,EAAE,CAAA;AAClE,CAAC;AAED;;;GAGG;AACH,SAAgB,oBAAoB,CAAC,GAAW,EAAE,WAAmD;IACnG,MAAM,SAAS,GAAG,2BAA2B,CAAC,GAAG,CAAC,CAAA;IAClD,MAAM,QAAQ,GAAG,GAAG,+BAAuB,GAAG,GAAG,IAAI,CAAA;IACrD,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;QAC7B,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QAChC,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,IAAI,CAAA;QACnC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;QACxC,OAAO,yBAAiB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,KAAK,OAAO,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,OAAO,GAAG,CAAC,CAAC,CAAA;IAC9F,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["/*\n * What the host did with a proposal, written into the conversation (ADR-0070 A12-a).\n *\n * The card used to change to \"executed\" the moment it was pressed, which is the one thing it knew\n * and the one thing that is not the answer. The host's answer comes back through a server door and\n * is stored as a `system` row, so three readers get it from the same place: the card that is open,\n * the card after a refresh, and the model on its next turn — a model that is not told its proposal\n * was refused goes on as though it had been applied.\n *\n * The line is the row's content. It is parsed back by the panel, so the shape is fixed here and the\n * panel's parser is tested against this formatter.\n */\n\n/** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */\nexport type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'\n\nexport interface ProposalOutcome {\n verdict: ProposalVerdict\n /** The host's own reason code, carried through untouched — no mapping table (ADR-0042). */\n errorCode?: string\n /** For a proposal made of several edits: how many went in, and how many did not. */\n applied?: number\n missed?: number\n}\n\nexport const PROPOSAL_OUTCOME_PREFIX = 'proposal '\n\nexport const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']\n\n/**\n * `proposal <key>: <verdict> (<errorCode>) 2/3`\n *\n * English, like the other lines the model reads. The panel turns it into the reader's language.\n */\nexport function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string {\n const reason = outcome.errorCode ? ` (${outcome.errorCode})` : ''\n const counted =\n typeof outcome.applied === 'number' || typeof outcome.missed === 'number'\n ? ` ${outcome.applied ?? 0}/${(outcome.applied ?? 0) + (outcome.missed ?? 0)}`\n : ''\n return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`\n}\n\n/*\n * \"Requested\" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).\n *\n * The card used to remember that it was pressed in the page's memory only. After a refresh a\n * proposal whose outcome never came back had its button again, and pressing it asked the host to\n * apply it a second time. The request is now a row, like the outcome, so every reader sees it: the\n * card after a refresh, another window, and the model (\"requested, no answer yet\").\n *\n * It is not a verdict — nothing happened yet. It only says the host was asked.\n */\nexport const PROPOSAL_REQUESTED = 'requested'\n\n/** `proposal <key>: requested` */\nexport function formatProposalRequestedLine(key: string): string {\n return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${PROPOSAL_REQUESTED}`\n}\n\n/**\n * Whether this proposal was already taken in this conversation — requested, or answered.\n * The caller passes the conversation's system lines; the answer is the rule, kept testable.\n */\nexport function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean {\n const requested = formatProposalRequestedLine(key)\n const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `\n return systemLines.some(line => {\n const text = (line ?? '').trim()\n if (text === requested) return true\n if (!text.startsWith(answered)) return false\n const rest = text.slice(answered.length)\n return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))\n })\n}\n"]}
@@ -58,12 +58,12 @@ export declare class ChatSession {
58
58
  * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
59
59
  * `private` 참여자만 읽는다.
60
60
  *
61
- * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로
62
- * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을
63
- * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면
64
- * 사적으로 오간 원문이 소급해서 공개된다.
61
+ * 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한
62
+ * 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을
63
+ * 그날까지의 규칙대로 읽기 위한 것이고, 새 행은 늘 값을 적는다.
65
64
  *
66
- * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.
65
+ * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보이고, 공개 대화는 도크가
66
+ * 다르게 그린다.
67
67
  *
68
68
  * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
69
69
  */
@@ -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) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | '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"]}
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,sQAAsQ;KACzQ,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) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | '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 * 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한\n * 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을\n * 그날까지의 규칙대로 읽기 위한 것이고, 새 행은 늘 값을 적는다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보이고, 공개 대화는 도크가\n * 다르게 그린다.\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"]}
@@ -89,6 +89,13 @@ export declare function filterInbox<T extends InboxEntry & {
89
89
  mine?: boolean;
90
90
  }>(entries: T[], filter?: InboxFilter): T[];
91
91
  export declare function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[];
92
+ /** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
93
+ export declare function searchReadableMessages(rows: readonly {
94
+ sessionId?: string;
95
+ content?: string;
96
+ role?: string;
97
+ redacted?: boolean;
98
+ }[], search: string): Set<string>;
92
99
  /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
93
100
  export declare const INBOX_SCAN_CAP = 500;
94
101
  /** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
@@ -21,6 +21,7 @@ exports.buildInbox = buildInbox;
21
21
  exports.applyHidden = applyHidden;
22
22
  exports.filterInbox = filterInbox;
23
23
  exports.searchInbox = searchInbox;
24
+ exports.searchReadableMessages = searchReadableMessages;
24
25
  exports.countUnreadSessions = countUnreadSessions;
25
26
  exports.activityPatch = activityPatch;
26
27
  exports.autoSessionName = autoSessionName;
@@ -122,6 +123,12 @@ function searchInbox(entries, search) {
122
123
  return entries;
123
124
  return entries.filter(e => `${e.name ?? ''}\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q));
124
125
  }
126
+ /** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
127
+ function searchReadableMessages(rows, search) {
128
+ const q = search.trim().toLowerCase();
129
+ return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))
130
+ .map(row => row.sessionId).filter((id) => !!id));
131
+ }
125
132
  /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
126
133
  exports.INBOX_SCAN_CAP = 500;
127
134
  /** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
@@ -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\nimport { mayReadSession } from './session-visibility.js'\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 /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\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 /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: 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 const owns = 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 if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .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 visibility: session.visibility ?? 'open',\n owner: owns.has(id),\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 */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\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\nimport { mayReadSession } from './session-visibility.js'\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 /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\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 /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: 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 const owns = 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 if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .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 visibility: session.visibility ?? 'open',\n owner: owns.has(id),\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 */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\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/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */\nexport function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {\n const q = search.trim().toLowerCase()\n return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))\n .map(row => row.sessionId).filter((id): id is string => !!id))\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,13 +1,16 @@
1
1
  export type SessionVisibility = 'open' | 'private';
2
2
  export declare const SESSION_VISIBILITIES: readonly SessionVisibility[];
3
3
  /**
4
- * 이렇게 만들어진 세션은 무엇으로 시작하나.
4
+ * 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**
5
5
  *
6
- * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든
7
- * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은
8
- * 자기가 있던 자리의 규칙으로 쓰였다.
6
+ * ── 앵커가 있으면 공개로 태어나던 것을 거둔 이유 (2026-09-30, 제품 오너) ──
7
+ * operato-office 의 대화는 앱 행(`application`)에 매달린다. 그래서 원장 · 급여를 묻는 경영자의 질문이 도메인
8
+ * 안 모든 직원에게 열린 방으로 태어났다. 목록은 그것을 「사업장 공개」 한 줄로만 말해서 공개와 비공개가 한눈에
9
+ * 갈리지 않았다. 앵커는 「무엇에 대한 이야기인가」이지 「누구에게 보이나」가 아니다.
10
+ *
11
+ * 공개는 owner 가 고른다(`setSessionVisibility`) — 화면이 여는 순간 되묻고, 공개 대화는 도크가 다르게 그린다.
9
12
  */
10
- export declare function initialVisibility(session: {
13
+ export declare function initialVisibility(_session?: {
11
14
  anchorType?: string | null;
12
15
  copyOf?: string | null;
13
16
  }): SessionVisibility;
@@ -11,23 +11,17 @@ exports.mayReadSession = mayReadSession;
11
11
  exports.mayWriteSession = mayWriteSession;
12
12
  exports.SESSION_VISIBILITIES = ['open', 'private'];
13
13
  /**
14
- * 이렇게 만들어진 세션은 무엇으로 시작하나.
14
+ * 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**
15
15
  *
16
- * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든
17
- * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은
18
- * 자기가 있던 자리의 규칙으로 쓰였다.
16
+ * ── 앵커가 있으면 공개로 태어나던 것을 거둔 이유 (2026-09-30, 제품 오너) ──
17
+ * operato-office 의 대화는 앱 행(`application`)에 매달린다. 그래서 원장 · 급여를 묻는 경영자의 질문이 도메인
18
+ * 안 모든 직원에게 열린 방으로 태어났다. 목록은 그것을 「사업장 공개」 한 줄로만 말해서 공개와 비공개가 한눈에
19
+ * 갈리지 않았다. 앵커는 「무엇에 대한 이야기인가」이지 「누구에게 보이나」가 아니다.
20
+ *
21
+ * 공개는 owner 가 고른다(`setSessionVisibility`) — 화면이 여는 순간 되묻고, 공개 대화는 도크가 다르게 그린다.
19
22
  */
20
- function initialVisibility(session) {
21
- if (session.copyOf)
22
- return 'private';
23
- /*
24
- * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`
25
- * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이
26
- * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`
27
- * 같은 개인 질문)」.
28
- */
29
- const anchored = !!session.anchorType && session.anchorType !== 'none';
30
- return anchored ? 'open' : 'private';
23
+ function initialVisibility(_session = {}) {
24
+ return 'private';
31
25
  }
32
26
  /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
33
27
  function mayReadSession(visibility, isParticipant) {
@@ -1 +1 @@
1
- {"version":3,"file":"session-visibility.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-visibility.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAaH,8CAWC;AAGD,wCAEC;AASD,0CAEC;AApCY,QAAA,oBAAoB,GAAiC,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;AAErF;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,OAA+D;IAC/F,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,SAAS,CAAA;IAEpC;;;;;OAKG;IACH,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,UAAU,KAAK,MAAM,CAAA;IACtE,OAAO,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;AACtC,CAAC;AAED,+DAA+D;AAC/D,SAAgB,cAAc,CAAC,UAAqC,EAAE,aAAsB;IAC1F,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC,KAAK,MAAM,IAAI,aAAa,CAAA;AAC3D,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,eAAe,CAAC,UAAqC,EAAE,aAAsB;IAC3F,OAAO,cAAc,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;AAClD,CAAC","sourcesContent":["/*\n * 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.\n *\n * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.\n */\n\nexport type SessionVisibility = 'open' | 'private'\n\nexport const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']\n\n/**\n * 이렇게 만들어진 세션은 무엇으로 시작하나.\n *\n * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든\n * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은\n * 자기가 있던 자리의 규칙으로 쓰였다.\n */\nexport function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {\n if (session.copyOf) return 'private'\n\n /*\n * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`\n * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이\n * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`\n * 같은 개인 질문)」.\n */\n const anchored = !!session.anchorType && session.anchorType !== 'none'\n return anchored ? 'open' : 'private'\n}\n\n/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */\nexport function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return (visibility ?? 'open') === 'open' || isParticipant\n}\n\n/**\n * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.\n *\n * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을\n * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는\n * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.\n */\nexport function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return mayReadSession(visibility, isParticipant)\n}\n"]}
1
+ {"version":3,"file":"session-visibility.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-visibility.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAgBH,8CAEC;AAGD,wCAEC;AASD,0CAEC;AA9BY,QAAA,oBAAoB,GAAiC,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;AAErF;;;;;;;;;GASG;AACH,SAAgB,iBAAiB,CAAC,WAAmE,EAAE;IACrG,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,+DAA+D;AAC/D,SAAgB,cAAc,CAAC,UAAqC,EAAE,aAAsB;IAC1F,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC,KAAK,MAAM,IAAI,aAAa,CAAA;AAC3D,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,eAAe,CAAC,UAAqC,EAAE,aAAsB;IAC3F,OAAO,cAAc,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;AAClD,CAAC","sourcesContent":["/*\n * 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.\n *\n * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.\n */\n\nexport type SessionVisibility = 'open' | 'private'\n\nexport const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']\n\n/**\n * 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**\n *\n * ── 앵커가 있으면 공개로 태어나던 것을 거둔 이유 (2026-09-30, 제품 오너) ──\n * operato-office 의 대화는 앱 행(`application`)에 매달린다. 그래서 원장 · 급여를 묻는 경영자의 질문이 도메인\n * 안 모든 직원에게 열린 방으로 태어났다. 목록은 그것을 「사업장 공개」 한 줄로만 말해서 공개와 비공개가 한눈에\n * 갈리지 않았다. 앵커는 「무엇에 대한 이야기인가」이지 「누구에게 보이나」가 아니다.\n *\n * 공개는 owner 가 고른다(`setSessionVisibility`) — 화면이 여는 순간 되묻고, 공개 대화는 도크가 다르게 그린다.\n */\nexport function initialVisibility(_session: { anchorType?: string | null; copyOf?: string | null } = {}): SessionVisibility {\n return 'private'\n}\n\n/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */\nexport function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return (visibility ?? 'open') === 'open' || isParticipant\n}\n\n/**\n * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.\n *\n * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을\n * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는\n * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.\n */\nexport function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean {\n return mayReadSession(visibility, isParticipant)\n}\n"]}
@@ -109,6 +109,7 @@ export declare class ChatSessionResolver {
109
109
  createAISession(anchorType: string, anchorId: string, name: string | undefined, context: ResolverContext): Promise<ChatSession>;
110
110
  renameChatSession(sessionId: string, name: string, context: ResolverContext): Promise<boolean>;
111
111
  reportProposalOutcome(sessionId: string, proposalKey: string, outcome: ProposalOutcome, context: ResolverContext): Promise<boolean>;
112
+ requestProposal(sessionId: string, proposalKey: string, context: ResolverContext): Promise<boolean>;
112
113
  /**
113
114
  * Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
114
115
  * Passes the write gate - a private conversation takes files from its people only - and the mode's
@@ -217,7 +217,22 @@ let ChatSessionResolver = class ChatSessionResolver {
217
217
  const all = await this._inboxEntries(anchorType, anchorId, context);
218
218
  let entries = opts.showHidden ? all : all.filter(e => !e.hidden);
219
219
  entries = (0, session_inbox_js_1.filterInbox)(entries, opts);
220
- return (0, session_inbox_js_1.searchInbox)(entries, opts.search);
220
+ if (!opts.search?.trim() || !entries.length)
221
+ return entries;
222
+ // Search only readable rooms, then pass their messages through the SAME reader gate.
223
+ // A match in a redacted answer must not reveal even that a conversation contains it.
224
+ const messages = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).find({
225
+ where: { session: { id: (0, typeorm_1.In)(entries.map(entry => entry.id)) } },
226
+ order: { createdAt: 'DESC', id: 'DESC' },
227
+ take: 2001
228
+ });
229
+ if (messages.length > 2000) {
230
+ throw new Error(context.t('ai-assistant.session.search-too-broad', { defaultValue: 'Too many messages to search here. Narrow the conversation filters and try again.' }));
231
+ }
232
+ const readable = await (0, reader_gate_js_1.readerGate)(context)(messages);
233
+ const matched = (0, session_inbox_js_1.searchReadableMessages)(readable, opts.search);
234
+ const names = new Set((0, session_inbox_js_1.searchInbox)(entries, opts.search).map(entry => entry.id));
235
+ return entries.filter(entry => names.has(entry.id) || matched.has(entry.id));
221
236
  }
222
237
  /**
223
238
  * 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
@@ -496,7 +511,7 @@ let ChatSessionResolver = class ChatSessionResolver {
496
511
  const session = await repo.save({
497
512
  domain,
498
513
  ...(0, session_anchor_js_1.anchorColumns)(anchorType, anchorId),
499
- /* 앵커가 있는 대화는 공개로 태어난다 — ADR-0073 판정 2. */
514
+ /* 새 대화는 비공개로 태어난다 — 공개는 owner 가 고른다(ADR-0073 결정 2, 2026-09-30 고침). */
500
515
  visibility: (0, session_visibility_js_1.initialVisibility)({ anchorType }),
501
516
  creator: user,
502
517
  updater: user
@@ -515,13 +530,8 @@ let ChatSessionResolver = class ChatSessionResolver {
515
530
  async createAISession(anchorType, anchorId, name, context) {
516
531
  const { domain, user, tx } = context.state;
517
532
  const repo = (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx);
518
- // 이름 미지정 시 자동 생성 — **같은 앵커**의 기존 세션 수 + 1.
519
- let finalName = name?.trim();
520
- if (!finalName) {
521
- const count = await repo.count({ where: (0, session_anchor_js_1.anchorWhere)(domain.id, anchorType, anchorId) });
522
- /* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
523
- finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' });
524
- }
533
+ // Do not prefill a numbered name: that prevents the first question from naming it.
534
+ const finalName = name?.trim() || null;
525
535
  const session = await repo.save({
526
536
  domain,
527
537
  ...(0, session_anchor_js_1.anchorColumns)(anchorType, anchorId),
@@ -585,6 +595,37 @@ let ChatSessionResolver = class ChatSessionResolver {
585
595
  (0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message: sysMsg });
586
596
  return true;
587
597
  }
598
+ async requestProposal(sessionId, proposalKey, context) {
599
+ const { domain, user, tx } = context.state;
600
+ const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } });
601
+ if (!session)
602
+ throw new Error(`ChatSession ${sessionId} not found`);
603
+ await writeGate(session, context, tx);
604
+ const key = (proposalKey ?? '').trim();
605
+ if (!key)
606
+ return false;
607
+ /* Only this conversation's proposal lines - a conversation is bounded, and the rule reads them all. */
608
+ const lines = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).find({
609
+ select: ['content'],
610
+ where: { session: { id: session.id }, role: 'system', content: (0, typeorm_1.Like)(`${proposal_outcome_js_1.PROPOSAL_OUTCOME_PREFIX}%`) }
611
+ });
612
+ if ((0, proposal_outcome_js_1.proposalAlreadyTaken)(key, lines.map(line => line.content)))
613
+ return false;
614
+ const previousLast = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).findOne({
615
+ where: { session: { id: session.id } },
616
+ order: { createdAt: 'DESC' }
617
+ });
618
+ const row = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).save({
619
+ session: { id: session.id },
620
+ role: 'system',
621
+ content: (0, proposal_outcome_js_1.formatProposalRequestedLine)(key),
622
+ creator: user,
623
+ updater: user,
624
+ parentMessage: previousLast ? { id: previousLast.id } : undefined
625
+ });
626
+ (0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message: row });
627
+ return true;
628
+ }
588
629
  /**
589
630
  * Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
590
631
  * Passes the write gate - a private conversation takes files from its people only - and the mode's
@@ -989,7 +1030,7 @@ tslib_1.__decorate([
989
1030
  (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
990
1031
  tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
991
1032
  tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
992
- tslib_1.__param(2, (0, type_graphql_1.Arg)('name', { nullable: true, description: 'Optional name. Defaults to a generated one, numbered within the anchor.' })),
1033
+ tslib_1.__param(2, (0, type_graphql_1.Arg)('name', { nullable: true, description: 'Optional name. If omitted, the first question names the conversation.' })),
993
1034
  tslib_1.__param(3, (0, type_graphql_1.Ctx)()),
994
1035
  tslib_1.__metadata("design:type", Function),
995
1036
  tslib_1.__metadata("design:paramtypes", [String, String, String, Object]),
@@ -1022,6 +1063,19 @@ tslib_1.__decorate([
1022
1063
  tslib_1.__metadata("design:paramtypes", [String, String, Object, Object]),
1023
1064
  tslib_1.__metadata("design:returntype", Promise)
1024
1065
  ], ChatSessionResolver.prototype, "reportProposalOutcome", null);
1066
+ tslib_1.__decorate([
1067
+ (0, type_graphql_1.Directive)('@transaction'),
1068
+ (0, type_graphql_1.Mutation)(() => Boolean, {
1069
+ description: "Ask before running a proposal: true when this conversation has not taken it yet, and the request is recorded as a system row; false when it was already requested or answered - then the host must not be asked again. The card reads its state from these rows, so a refreshed panel or another window does not offer the same proposal twice (ADR-0097)."
1070
+ }),
1071
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
1072
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
1073
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('proposalKey')),
1074
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
1075
+ tslib_1.__metadata("design:type", Function),
1076
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
1077
+ tslib_1.__metadata("design:returntype", Promise)
1078
+ ], ChatSessionResolver.prototype, "requestProposal", null);
1025
1079
  tslib_1.__decorate([
1026
1080
  (0, type_graphql_1.Directive)('@transaction'),
1027
1081
  (0, type_graphql_1.Mutation)(() => [ChatAttachmentInfo], {