@things-factory/ai-assistant 10.1.208 → 10.1.212

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 (167) hide show
  1. package/client/components/assistant-chat.ts +238 -9
  2. package/client/components/assistant-dock.ts +9 -4
  3. package/client/components/assistant-invite.ts +138 -0
  4. package/client/components/assistant-session-manager.ts +53 -12
  5. package/client/components/proposal-request.test.ts +10 -0
  6. package/client/components/roster-identity.test.ts +2 -12
  7. package/client/components/roster-identity.ts +0 -6
  8. package/client/utils/assistant-session-actions.ts +3 -1
  9. package/client/utils/assistant-session-admin.ts +9 -2
  10. package/client/utils/assistant-session-controller.ts +1 -1
  11. package/client/utils/assistant-typing.test.ts +33 -0
  12. package/client/utils/assistant-typing.ts +53 -0
  13. package/dist-client/components/assistant-chat.d.ts +28 -0
  14. package/dist-client/components/assistant-chat.js +259 -8
  15. package/dist-client/components/assistant-chat.js.map +1 -1
  16. package/dist-client/components/assistant-dock.js +12 -4
  17. package/dist-client/components/assistant-dock.js.map +1 -1
  18. package/dist-client/components/assistant-invite.d.ts +39 -0
  19. package/dist-client/components/assistant-invite.js +150 -0
  20. package/dist-client/components/assistant-invite.js.map +1 -0
  21. package/dist-client/components/assistant-session-manager.d.ts +5 -1
  22. package/dist-client/components/assistant-session-manager.js +40 -10
  23. package/dist-client/components/assistant-session-manager.js.map +1 -1
  24. package/dist-client/components/proposal-request.test.js +8 -0
  25. package/dist-client/components/proposal-request.test.js.map +1 -1
  26. package/dist-client/components/roster-identity.d.ts +0 -2
  27. package/dist-client/components/roster-identity.js +0 -7
  28. package/dist-client/components/roster-identity.js.map +1 -1
  29. package/dist-client/components/roster-identity.test.js +2 -13
  30. package/dist-client/components/roster-identity.test.js.map +1 -1
  31. package/dist-client/tsconfig.tsbuildinfo +1 -1
  32. package/dist-client/utils/assistant-session-actions.d.ts +1 -0
  33. package/dist-client/utils/assistant-session-actions.js +3 -1
  34. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  35. package/dist-client/utils/assistant-session-admin.d.ts +5 -2
  36. package/dist-client/utils/assistant-session-admin.js +3 -0
  37. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  38. package/dist-client/utils/assistant-session-controller.d.ts +1 -1
  39. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  40. package/dist-client/utils/assistant-typing.d.ts +20 -0
  41. package/dist-client/utils/assistant-typing.js +48 -0
  42. package/dist-client/utils/assistant-typing.js.map +1 -0
  43. package/dist-client/utils/assistant-typing.test.d.ts +1 -0
  44. package/dist-client/utils/assistant-typing.test.js +30 -0
  45. package/dist-client/utils/assistant-typing.test.js.map +1 -0
  46. package/dist-server/service/assistant-chat-resolver.js +5 -12
  47. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  48. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  49. package/dist-server/service/assistant-mode/index.js +2 -1
  50. package/dist-server/service/assistant-mode/index.js.map +1 -1
  51. package/dist-server/service/assistant-mode/mode-categories.js +9 -2
  52. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  53. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  54. package/dist-server/service/assistant-mode/screen-rules.js +10 -3
  55. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  56. package/dist-server/service/chat-message/chat-message-room.js +3 -1
  57. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  58. package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
  59. package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
  60. package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
  62. package/dist-server/service/chat-message/chat-typing.js +40 -0
  63. package/dist-server/service/chat-message/chat-typing.js.map +1 -0
  64. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  65. package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
  66. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  67. package/dist-server/service/chat-session/attachment-tools.js +2 -2
  68. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  69. package/dist-server/service/chat-session/chat-session.d.ts +2 -1
  70. package/dist-server/service/chat-session/chat-session.js +1 -1
  71. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  72. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
  73. package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
  74. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
  75. package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
  76. package/dist-server/service/chat-session/session-activity-publish.js +5 -2
  77. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
  78. package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
  79. package/dist-server/service/chat-session/session-visibility.js +28 -3
  80. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  81. package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
  82. package/dist-server/service/chat-session/session-write-gate.js +1 -1
  83. package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
  84. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  85. package/dist-server/service/chat-session-participant/index.js +4 -1
  86. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  87. package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
  88. package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
  89. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  90. package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
  91. package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
  92. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
  93. package/dist-server/service/chat-session-participant/read-roster.js +5 -2
  94. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
  95. package/dist-server/service/chat-session-resolver.d.ts +27 -0
  96. package/dist-server/service/chat-session-resolver.js +226 -25
  97. package/dist-server/service/chat-session-resolver.js.map +1 -1
  98. package/dist-server/service/data-entry/entry-target.d.ts +6 -0
  99. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  100. package/dist-server/service/data-entry/entry-tools.js +2 -2
  101. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  102. package/dist-server/service/data-entry/entry-update.js +1 -1
  103. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  104. package/dist-server/service/data-read/list-read.d.ts +10 -0
  105. package/dist-server/service/data-read/list-read.js +12 -0
  106. package/dist-server/service/data-read/list-read.js.map +1 -1
  107. package/dist-server/service/data-read/read-target.d.ts +5 -0
  108. package/dist-server/service/data-read/read-target.js.map +1 -1
  109. package/dist-server/service/index.d.ts +4 -2
  110. package/dist-server/service/index.js +9 -1
  111. package/dist-server/service/index.js.map +1 -1
  112. package/dist-server/service/testing/conversation-script.d.ts +1 -1
  113. package/dist-server/service/testing/conversation-script.js.map +1 -1
  114. package/dist-server/service/testing/mode-conformance.js +2 -0
  115. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  116. package/dist-server/tsconfig.tsbuildinfo +1 -1
  117. package/package.json +6 -6
  118. package/server/service/assistant-chat-resolver.ts +7 -12
  119. package/server/service/assistant-mode/index.ts +1 -1
  120. package/server/service/assistant-mode/mode-categories.ts +11 -3
  121. package/server/service/assistant-mode/mode-front.test.ts +53 -1
  122. package/server/service/assistant-mode/screen-rules.test.ts +7 -1
  123. package/server/service/assistant-mode/screen-rules.ts +11 -2
  124. package/server/service/chat-message/chat-message-room.test.ts +7 -2
  125. package/server/service/chat-message/chat-message-room.ts +3 -1
  126. package/server/service/chat-message/chat-typing-subscription.ts +52 -0
  127. package/server/service/chat-message/chat-typing.test.ts +45 -0
  128. package/server/service/chat-message/chat-typing.ts +48 -0
  129. package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
  130. package/server/service/chat-session/attachment-tool-result.ts +7 -0
  131. package/server/service/chat-session/attachment-tools.ts +3 -3
  132. package/server/service/chat-session/chat-session.ts +3 -2
  133. package/server/service/chat-session/group-room.test.ts +233 -0
  134. package/server/service/chat-session/session-activity-broadcast.ts +20 -0
  135. package/server/service/chat-session/session-activity-publish.ts +6 -1
  136. package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
  137. package/server/service/chat-session/session-visibility.test.ts +32 -1
  138. package/server/service/chat-session/session-visibility.ts +33 -4
  139. package/server/service/chat-session/session-write-gate.test.ts +7 -1
  140. package/server/service/chat-session/session-write-gate.ts +2 -2
  141. package/server/service/chat-session-participant/index.ts +2 -0
  142. package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
  143. package/server/service/chat-session-participant/invite-notification.ts +43 -5
  144. package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
  145. package/server/service/chat-session-participant/mention-invite.ts +82 -28
  146. package/server/service/chat-session-participant/read-roster.test.ts +13 -4
  147. package/server/service/chat-session-participant/read-roster.ts +6 -3
  148. package/server/service/chat-session-resolver.ts +204 -27
  149. package/server/service/data-entry/data-entry-update.test.ts +8 -0
  150. package/server/service/data-entry/entry-target.ts +6 -0
  151. package/server/service/data-entry/entry-tools.ts +2 -2
  152. package/server/service/data-entry/entry-update.ts +1 -1
  153. package/server/service/data-read/list-missing.test.ts +49 -0
  154. package/server/service/data-read/list-read.ts +18 -0
  155. package/server/service/data-read/read-target.ts +5 -0
  156. package/server/service/index.ts +6 -1
  157. package/server/service/testing/conversation-script.ts +1 -1
  158. package/server/service/testing/mode-conformance.ts +1 -0
  159. package/test/assistant-dock.test.ts +5 -1
  160. package/test/assistant-session-manager.test.ts +4 -2
  161. package/test/open-doors.test.ts +10 -5
  162. package/translations/en.json +29 -4
  163. package/translations/es.json +29 -4
  164. package/translations/ja.json +29 -4
  165. package/translations/ko.json +30 -5
  166. package/translations/ms.json +29 -4
  167. package/translations/zh.json +29 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.208",
3
+ "version": "10.1.212",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -35,12 +35,12 @@
35
35
  "@operato/shell": "^10.29.39",
36
36
  "@operato/styles": "^10.0.0",
37
37
  "@operato/utils": "^10.29.39",
38
- "@things-factory/ai-client-base": "^10.1.208",
38
+ "@things-factory/ai-client-base": "^10.1.212",
39
39
  "@things-factory/apptool-base": "^10.1.208",
40
- "@things-factory/attachment-base": "^10.1.208",
41
- "@things-factory/auth-base": "^10.1.208",
40
+ "@things-factory/attachment-base": "^10.1.212",
41
+ "@things-factory/auth-base": "^10.1.212",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.208",
43
+ "@things-factory/setting-base": "^10.1.212",
44
44
  "@things-factory/shell": "^10.1.208",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
@@ -55,5 +55,5 @@
55
55
  "copyfiles": "^2.4.1",
56
56
  "rimraf": "^5.0.0"
57
57
  },
58
- "gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
58
+ "gitHead": "1e1cdb79fca2915580429af451b6cf1dae653a93"
59
59
  }
@@ -45,8 +45,8 @@ import { ChatSessionParticipant } from './chat-session-participant/chat-session-
45
45
  import { doorsForAnswer } from './chat-message/message-doors.js'
46
46
  import { judgeSessionKind } from './chat-session/session-kind.js'
47
47
  import { readerGate } from './chat-message/reader-gate.js'
48
- import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
49
- import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
48
+ import { seatTurn } from './chat-session-participant/mention-invite.js'
49
+ import { sendInviteNotification } from './chat-session-participant/invite-notification.js'
50
50
  import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
51
51
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
52
52
  import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-session/attachment-policy.js'
@@ -383,9 +383,10 @@ export class AssistantChatResolver {
383
383
 
384
384
  /*
385
385
  * 사람이 `@이름` 으로 부른 사람은 이 대화에 들어온다(ADR-0071 결정 1). 이 문은 **사람의 턴**을
386
- * 저장하는 문이라 그 일을 한다 — 모델의 답이나 도구 결과는 아무도 초대하지 않는다.
386
+ * 저장하는 문이라 그 일을 한다 — 모델의 답이나 도구 결과는 아무도 초대하지 않는다. 공개 방에서 말한 사람도
387
+ * 이때 자리에 앉는다(`seatTurn`) — 참여자 줄 · 접속 표시 · 안 읽음이 실제로 말한 사람과 맞도록.
387
388
  */
388
- const invite = await inviteMentionedUsers({
389
+ const invite = await seatTurn({
389
390
  content: lastUserMessage.content,
390
391
  sessionId: session.id!,
391
392
  domainId: domain.id,
@@ -393,14 +394,8 @@ export class AssistantChatResolver {
393
394
  participants: getRepository(ChatSessionParticipant),
394
395
  visibility: (session as any).visibility
395
396
  })
396
- if (invite.invited.length > 0) {
397
- const payload = buildInviteNotification(invite.invited, undefined, user, (session as any)?.name)
398
- try {
399
- if (payload) await (context.state as any)?.notify?.(payload)
400
- } catch {
401
- /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
402
- }
403
- }
397
+ /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다(`sendInviteNotification`). */
398
+ await sendInviteNotification((context.state as any)?.notify, invite.invited, user, (session as any)?.name, undefined, requestTranslator(context))
404
399
 
405
400
  publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved } as any)
406
401
  publishSessionActivity({
@@ -12,6 +12,6 @@ export {
12
12
  clearAssistantModes
13
13
  } from './registry.js'
14
14
  export { resolveModeSettings, assistantModeView } from './resolve.js'
15
- export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
15
+ export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
16
16
  export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
17
17
  export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
@@ -1,5 +1,6 @@
1
1
  /* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */
2
- import { readTargetOf } from '../data-read/read-target.js'
2
+ import { entryTargetOf } from '../data-entry/entry-target.js'
3
+ import { detailReadOf, readTargetOf } from '../data-read/read-target.js'
3
4
  /**
4
5
  * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
5
6
  * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
@@ -42,7 +43,14 @@ type FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }
42
43
  */
43
44
  export function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {
44
45
  const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
45
- return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && key.split('.').pop() === screen.subjectAxis))
46
+ return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && axesOfKey(mode, key).includes(screen.subjectAxis)))
47
+ }
48
+
49
+ /** The records a key is about: its last part, and the axes a card joins or a whole answer covers (EntryTarget.axes · DetailRead.axes). */
50
+ function axesOfKey(mode: FrontMode, key: string): string[] {
51
+ const card = (mode.entryTargets ?? []).includes(key) ? entryTargetOf(key)?.axes ?? [] : []
52
+ const answer = (mode.readTargets ?? []).includes(key) ? detailReadOf(key)?.axes ?? [] : []
53
+ return [key.split('.').pop()!, ...card, ...answer]
46
54
  }
47
55
 
48
56
  /**
@@ -66,6 +74,6 @@ export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): s
66
74
  export function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {
67
75
  const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
68
76
  const pages = [...new Set(keys.map(routeOf).filter(Boolean) as string[])]
69
- const axes = [...new Set(keys.map(key => key.split('.').pop()!))]
77
+ const axes = [...new Set(keys.flatMap(key => axesOfKey(mode, key)))]
70
78
  return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]
71
79
  }
@@ -2,9 +2,10 @@
2
2
  * What a turn puts in front when its mode's tools do not fit in one call (ADR-0061 decision 9) - the open screen's
3
3
  * records, found from what is already declared: a list's screen route and a key's last part.
4
4
  */
5
- import { clearReadTargets, declareReadTarget } from '../data-read/read-target'
5
+ import { clearReadTargets, declareDetailRead, declareReadTarget } from '../data-read/read-target'
6
6
  import { listReadTarget } from '../data-read/list-read'
7
7
  import { modeFrontCategories, modeScreens, screenTargetKeys } from './mode-categories'
8
+ import { clearEntryTargets, declareEntryTarget } from '../data-entry/entry-target'
8
9
 
9
10
  const list = (key: string, route?: string) =>
10
11
  listReadTarget({ key, title: `title.${key}`, name: key, listQuery: 'rows', selection: 'id name', columns: [{ name: 'name', type: 'string' }], ...(route ? { route } : {}) } as any)
@@ -63,3 +64,54 @@ describe('the open screen decides what goes in front', () => {
63
64
  ])
64
65
  })
65
66
  })
67
+
68
+ /* 2026-10-24: asked to link a deposit from its 360, the model read the evidence and never opened the link card. */
69
+ describe('a card about several records is in front on each of their screens', () => {
70
+ const link = {
71
+ key: 'office.tax-invoice-binding',
72
+ labelKey: 'label.link',
73
+ description: 'a link of an invoice to a bank line or a round.',
74
+ mutation: 'mutation ($binding: Binding!) { bind(binding: $binding) { id } }',
75
+ variables: (row: any) => ({ binding: row.values }),
76
+ appliedOf: (data: any) => ({ id: data?.bind?.id }),
77
+ fields: [{ name: 'amount', type: 'number' as const, required: true }],
78
+ axes: ['cash-transaction', 'tax-invoice', 'contract-round']
79
+ }
80
+ const linking = { ...mode, entryTargets: [...mode.entryTargets, link.key] }
81
+ beforeEach(() => {
82
+ clearEntryTargets()
83
+ declareEntryTarget(link as any)
84
+ })
85
+
86
+ it('★ a deposit\'s 360 puts the link card in front with the deposit\'s own', () => {
87
+ expect(screenTargetKeys(linking, { subjectAxis: 'cash-transaction' })).toEqual(['office.cash-transaction', 'office.tax-invoice-binding'])
88
+ expect(modeFrontCategories(linking, { subjectAxis: 'cash-transaction' })).toContain('data-entry:office.tax-invoice-binding')
89
+ expect(screenTargetKeys(linking, { subjectAxis: 'contract-round' })).toEqual(['office.contract-round', 'office.tax-invoice-binding'])
90
+ })
91
+
92
+ it('★ not on a screen of a record it does not join, and not for a mode that does not enter it', () => {
93
+ expect(screenTargetKeys(linking, { subjectAxis: 'contract' })).toEqual(['office.contract'])
94
+ expect(screenTargetKeys(mode, { subjectAxis: 'cash-transaction' })).toEqual(['office.cash-transaction'])
95
+ })
96
+
97
+ it('the boot report walks the axes a card declares, so its screens are counted against the budget', () => {
98
+ expect(modeScreens(linking)).toContainEqual({ subjectAxis: 'tax-invoice' })
99
+ })
100
+ })
101
+
102
+ /* 2026-10-24: asked on a deposit's 360 why it was in the management check, the model never opened the check read. */
103
+ describe('a whole answer about any record is in front on their screens', () => {
104
+ const checks = { key: 'office.checks', description: 'the management check.', query: 'query { officeChecks { today } }', axes: ['cash-transaction', 'contract'] }
105
+ const reading = { ...mode, readTargets: [...mode.readTargets, checks.key] }
106
+ beforeEach(() => declareDetailRead(checks as any))
107
+
108
+ it('★ the check read is in front on a deposit\'s and a contract\'s 360', () => {
109
+ expect(modeFrontCategories(reading, { subjectAxis: 'cash-transaction' })).toContain('data-read:office.checks')
110
+ expect(modeFrontCategories(reading, { subjectAxis: 'contract' })).toContain('data-read:office.checks')
111
+ })
112
+
113
+ it('★ not on a record it does not cover, nor for a mode that does not read it', () => {
114
+ expect(modeFrontCategories(reading, { subjectAxis: 'expense-line' })).not.toContain('data-read:office.checks')
115
+ expect(modeFrontCategories(mode, { subjectAxis: 'cash-transaction' })).not.toContain('data-read:office.checks')
116
+ })
117
+ })
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
3
3
  */
4
- import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules'
4
+ import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules'
5
5
 
6
6
  describe('the next request an answer names', () => {
7
7
  it('★ the last line is taken off the reply and becomes the suggestion', () => {
@@ -67,5 +67,11 @@ describe('the prompt every conversation gets', () => {
67
67
  expect(NEXT_REQUEST_RULE).toMatch(/no quotation marks/)
68
68
  expect(NEXT_REQUEST_RULE).toMatch(/Never a yes to your own question/)
69
69
  expect(NEXT_REQUEST_RULE).toMatch(/no ids or keys/)
70
+ /* 2026-10-24, gpt-5.4-nano answering in Korean wrote 「다음:」 - the line stayed in the answer. */
71
+ expect(NEXT_REQUEST_RULE).toMatch(/the word Next: in English whatever language you answer in/)
72
+ /* 2026-10-24: the asker's name in the message's [writer → addressee] bracket was searched as a counterparty. */
73
+ expect(conversationPrompt('x')).toContain(SPEAKER_RULE)
74
+ expect(SPEAKER_RULE).toMatch(/\[writer → addressee\]/)
75
+ expect(SPEAKER_RULE).toMatch(/Never search for, filter by or fill a field with the writer's name/)
70
76
  })
71
77
  })
@@ -26,12 +26,21 @@ export const FINISH_RULE =
26
26
  'choice that only the person can make, and then ask just that choice. Answer briefly: what was found and what the card ' +
27
27
  'does, in the person\'s words - no internal ids, keys, field names or codes.'
28
28
 
29
+ /**
30
+ * Who spoke, never what was asked (ADR-0070 A15 decision 2 labels every person's message `[writer → addressee]`, a
31
+ * conversation of one included). Asked 「거래처 미등록 입출금에 등록 거래처를 연결해 줘」, the model read the asker's name in
32
+ * that bracket as part of the request and searched the bank lines for it (2026-10-24).
33
+ */
34
+ export const SPEAKER_RULE =
35
+ "A person's message begins with [writer → addressee]: it says who wrote it and to whom, nothing more. Never search for, " +
36
+ 'filter by or fill a field with the writer\'s name unless the request itself names them.'
37
+
29
38
  /** The line a reply ends with when a next request is natural (§4-1). */
30
39
  const NEXT_PREFIX = 'Next:'
31
40
 
32
41
  /** §4-1 — the reply names the next request, and the input offers it. */
33
42
  export const NEXT_REQUEST_RULE =
34
- `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
43
+ `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>" - the word ${NEXT_PREFIX} in English whatever language you answer in: ` +
35
44
  'a new request the person is most likely to make next, exactly the words they would type, in the language they wrote in - ' +
36
45
  'no quotation marks, no "say ..." or "ask me ...", no ids or keys. Never a yes to your own question and never what this ' +
37
46
  'reply could have done - do that now instead. Offer only what this conversation can do with its tools. ' +
@@ -39,7 +48,7 @@ export const NEXT_REQUEST_RULE =
39
48
 
40
49
  /** A mode's persona with the rules every conversation follows. */
41
50
  export function conversationPrompt(modePrompt: string): string {
42
- return [modePrompt.trim(), ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE].join('\n\n')
51
+ return [modePrompt.trim(), ABSENCE_RULE, FINISH_RULE, SPEAKER_RULE, NEXT_REQUEST_RULE].join('\n\n')
43
52
  }
44
53
 
45
54
  /** Quotation marks inside a request: it quotes another sentence, so it is about a request, not one. */
@@ -92,7 +92,7 @@ describe('chatRoomLookup — both questions stay inside the caller\'s domain', (
92
92
  }
93
93
 
94
94
  it('★ the session and the seat are both looked up with the domain', async () => {
95
- const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
95
+ const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1', role: 'member' })
96
96
  const lookup = chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' })
97
97
 
98
98
  expect(await lookup.visibility()).toBe('private')
@@ -106,8 +106,13 @@ describe('chatRoomLookup — both questions stay inside the caller\'s domain', (
106
106
  expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' }).visibility()).toBeUndefined()
107
107
  })
108
108
 
109
+ it('★ a hide-only viewer row is not a seat - the stream asks what the stored path asks', async () => {
110
+ const { deps } = repos({ id: 's1', visibility: 'group' }, { id: 'p1', role: 'viewer' })
111
+ expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' }).isParticipant()).toBe(false)
112
+ })
113
+
109
114
  it('a caller with no user sits nowhere', async () => {
110
- const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
115
+ const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1', role: 'member' })
111
116
  expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: undefined }).isParticipant()).toBe(false)
112
117
  expect(seen).toEqual([])
113
118
  })
@@ -13,6 +13,7 @@ import { filter, pipe } from 'graphql-yoga'
13
13
  import { pubsub } from '@things-factory/shell'
14
14
 
15
15
  import { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'
16
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
16
17
 
17
18
  interface FindOne {
18
19
  findOne(options: { where: any; select?: any }): Promise<any>
@@ -37,7 +38,8 @@ export function chatRoomLookup(
37
38
  const seat = await repos.participants.findOne({
38
39
  where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }
39
40
  })
40
- return !!seat
41
+ /* A hide-only `viewer` row is not a seat - the stored path asks the same (`isSessionParticipant`). */
42
+ return isRoomRole(seat?.role)
41
43
  }
42
44
  }
43
45
  }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * ChatTypingSubscription - "someone is typing" in a conversation, live. The rules are in `chat-typing.ts`;
3
+ * this file holds only pubsub and the decorators, which jest cannot read.
4
+ */
5
+ import { Resolver, Subscription, Root, Arg, ObjectType, Field } from 'type-graphql'
6
+ import { filter, pipe } from 'graphql-yoga'
7
+ import { assertDomainSubscribeAllowed, getRepository, pubsub } from '@things-factory/shell'
8
+
9
+ import { ChatSession } from '../chat-session/chat-session.js'
10
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
11
+ import { mayHearRoom } from './chat-message-broadcast.js'
12
+ import { chatRoomLookup } from './chat-message-room.js'
13
+ import { CHAT_TYPING_TOPIC, chatTypingFilter, type ChatTypingPayload } from './chat-typing.js'
14
+
15
+ @ObjectType({ description: 'Someone in the conversation is typing. Nothing is stored; the screen lets the notice fade without the next one.' })
16
+ export class ChatTyping {
17
+ @Field({ description: 'Conversation the person is typing in.' })
18
+ sessionId!: string
19
+
20
+ @Field({ description: 'User who is typing.' })
21
+ userId!: string
22
+
23
+ @Field({ description: 'Name the room shows for that user.' })
24
+ name!: string
25
+
26
+ @Field({ description: 'When the notice was sent (ISO).' })
27
+ at!: string
28
+ }
29
+
30
+ @Resolver(ChatTyping)
31
+ export class ChatTypingSubscription {
32
+ @Subscription(() => ChatTyping, {
33
+ description: 'Subscribes to "someone is typing" notices of a conversation the caller may read - never the caller\'s own.',
34
+ subscribe: async ({ args, context }) => {
35
+ const { domain, user } = context.state
36
+ const { sessionId } = args
37
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
38
+
39
+ /* The same room question the messages ask - refused in the words of a missing session. */
40
+ const room = chatRoomLookup(
41
+ { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
42
+ { domainId: domain.id, sessionId, userId: user?.id }
43
+ )
44
+ if (!(await mayHearRoom(room))) throw new Error('chat session not found')
45
+
46
+ return pipe(pubsub.subscribe(CHAT_TYPING_TOPIC), filter(chatTypingFilter(domain.id, sessionId, user?.id, room)))
47
+ }
48
+ })
49
+ chatTyping(@Root() payload: ChatTypingPayload, @Arg('sessionId') sessionId: string): ChatTyping {
50
+ return { sessionId: payload.sessionId, userId: payload.userId, name: payload.name, at: payload.at }
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * "Someone is typing" - who hears it. The same room question the messages ask, asked again at every notice,
3
+ * and never the listener's own notice.
4
+ */
5
+ import { chatTypingFilter, typingNotice } from './chat-typing'
6
+
7
+ const room = (visibility: string | null | undefined, seated: boolean) => ({ visibility: async () => visibility, isParticipant: async () => seated })
8
+ const notice = (over: any = {}) => ({ domainId: 'd1', sessionId: 's1', userId: 'u2', name: 'kim', at: '2026-10-24T00:00:00.000Z', ...over })
9
+
10
+ describe('chat typing', () => {
11
+ it('★ a seated listener of a group hears the others typing', async () => {
12
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('group', true))(notice())).toBe(true)
13
+ })
14
+
15
+ it('★ someone outside a group or private room hears nothing', async () => {
16
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('group', false))(notice())).toBe(false)
17
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('private', false))(notice())).toBe(false)
18
+ })
19
+
20
+ it('★ sent out while listening, the next notice does not reach them', async () => {
21
+ let seated = true
22
+ const filter = chatTypingFilter('d1', 's1', 'u1', { visibility: async () => 'group', isParticipant: async () => seated })
23
+ expect(await filter(notice())).toBe(true)
24
+ seated = false
25
+ expect(await filter(notice())).toBe(false)
26
+ })
27
+
28
+ it('anyone in the domain hears an open room', async () => {
29
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('open', false))(notice())).toBe(true)
30
+ })
31
+
32
+ it('never your own notice, never another session or domain, never a missing room', async () => {
33
+ const filter = chatTypingFilter('d1', 's1', 'u1', room('open', false))
34
+ expect(await filter(notice({ userId: 'u1' }))).toBe(false)
35
+ expect(await filter(notice({ sessionId: 's2' }))).toBe(false)
36
+ expect(await filter(notice({ domainId: 'd2' }))).toBe(false)
37
+ expect(await chatTypingFilter('d1', 's1', 'u1', room(undefined, true))(notice())).toBe(false)
38
+ })
39
+
40
+ it('the notice says who and when, a name on one line', () => {
41
+ const now = new Date('2026-10-24T01:02:03.000Z')
42
+ expect(typingNotice({ domainId: 'd1', sessionId: 's1', user: { id: 'u2', name: 'Kim\nJi' } }, now)).toEqual({ domainId: 'd1', sessionId: 's1', userId: 'u2', name: 'Kim Ji', at: '2026-10-24T01:02:03.000Z' })
43
+ expect(typingNotice({ domainId: 'd1', sessionId: 's1', user: {} })).toBeNull()
44
+ })
45
+ })
@@ -0,0 +1,48 @@
1
+ /*
2
+ * "Someone is typing" - the room's live line, pure part (topic, payload, filter). No pubsub, no decorators,
3
+ * so the tests run what the subscription runs (`chat-typing.test.ts`).
4
+ *
5
+ * Nothing is stored. A person typing is not a message, and a line that outlives the typing would lie, so a
6
+ * notice says only who and when, and the screen lets it fade after a few seconds without a new one.
7
+ *
8
+ * Who hears it is the room's question, asked as the messages ask it (`mayHearRoom`): an open room anyone in
9
+ * the domain, a group or private room the people seated in it - at subscribe time and again at every notice,
10
+ * because a member can be sent out while listening. Who may say it is the write gate's question: only a
11
+ * person who may write here is typing here.
12
+ */
13
+ import { mayHearRoom, type ChatRoomLookup } from './chat-message-broadcast.js'
14
+
15
+ export const CHAT_TYPING_TOPIC = 'ai-assistant-chat-typing'
16
+
17
+ /** How long a notice stands on the screen without the next one. The client sends one every few seconds while typing. */
18
+ export const TYPING_NOTICE_MS = 6_000
19
+
20
+ export interface ChatTypingPayload {
21
+ domainId: string
22
+ sessionId: string
23
+ userId: string
24
+ /** The name the room shows - the same name a message's sender line shows. */
25
+ name: string
26
+ /** ISO time the notice was sent. */
27
+ at: string
28
+ }
29
+
30
+ /** The notice a typing person sends - a name is cut to one line, as the room's other names are. */
31
+ export function typingNotice(input: { domainId: string; sessionId: string; user: { id?: string; name?: string | null; email?: string | null } }, now = new Date()): ChatTypingPayload | null {
32
+ if (!input.user?.id || !input.sessionId) return null
33
+ const name = String(input.user.name || input.user.email || '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80)
34
+ return { domainId: input.domainId, sessionId: input.sessionId, userId: input.user.id, name, at: now.toISOString() }
35
+ }
36
+
37
+ /**
38
+ * Passes a notice of the same domain and session, not the listener's own, and only while the room is open to
39
+ * the listener.
40
+ */
41
+ export function chatTypingFilter(domainId: string, sessionId: string, listenerId: string | undefined, room: ChatRoomLookup) {
42
+ return async (payload: ChatTypingPayload): Promise<boolean> =>
43
+ !!payload &&
44
+ payload.domainId === domainId &&
45
+ payload.sessionId === sessionId &&
46
+ payload.userId !== listenerId &&
47
+ (await mayHearRoom(room))
48
+ }
@@ -4,7 +4,7 @@
4
4
  /* The source, not the built package - the two land together and the test must see the change before a build. */
5
5
  import { takeToolImages } from '../../../../ai-client-base/server/service/agentic-loop/agentic-loop'
6
6
 
7
- import { attachmentToolResult } from './attachment-tool-result'
7
+ import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result'
8
8
 
9
9
  describe('a file opened again in a later turn', () => {
10
10
  const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 9])
@@ -24,3 +24,11 @@ describe('a file opened again in a later turn', () => {
24
24
  expect(attachmentToolResult('t1', { type: 'text', text: 'a', name: 'x.csv', truncated: true })).toMatchObject({ note: 'the file is longer; this is its beginning.' })
25
25
  })
26
26
  })
27
+
28
+ /* 2026-10-24: a deposit's id was asked for as a file eight times in one turn. */
29
+ describe('an id that names no file', () => {
30
+ it('★ is told it is no attachment and not to ask again', () => {
31
+ expect(NO_SUCH_ATTACHMENT).toMatch(/it is not an attachment/)
32
+ expect(NO_SUCH_ATTACHMENT).toMatch(/Do not ask for it again/)
33
+ })
34
+ })
@@ -16,3 +16,10 @@ export function attachmentToolResult(attachmentId: string, file: FileForModel) {
16
16
  ? { attachmentId, name: file.name, type: 'image', images: [{ data: file.data, mediaType: file.mediaType }], note: 'the picture is attached right after this result - look at it.' }
17
17
  : { attachmentId, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
18
18
  }
19
+
20
+ /*
21
+ * What an id that names no file of the conversation gets (2026-10-24): given a deposit's id, the model asked for it as a
22
+ * file eight times in one turn. The answer says the id is no file and where a record is read instead.
23
+ */
24
+ export const NO_SUCH_ATTACHMENT =
25
+ 'there is no file with this id in a conversation here - it is not an attachment. Do not ask for it again; a record is read with its own read tool.'
@@ -18,7 +18,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
19
  import { textCharsOf } from './attachment-policy.js'
20
20
  import { getAssistantMode } from '../assistant-mode/registry.js'
21
- import { attachmentToolResult } from './attachment-tool-result.js'
21
+ import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result.js'
22
22
 
23
23
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
24
24
 
@@ -41,12 +41,12 @@ const readChatAttachment: ToolSpec = {
41
41
 
42
42
  const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
43
43
  const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
44
- if (!row || !session) return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
44
+ if (!row || !session) return { absent: 'not-found', attachmentId: id, message: NO_SUCH_ATTACHMENT }
45
45
 
46
46
  const participant = await isSessionParticipant(domain.id, session.id, user?.id)
47
47
  if (!mayReadSession(session.visibility, participant)) {
48
48
  /* Same answer as absent - saying "it exists but is private" would itself leak. */
49
- return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
49
+ return { absent: 'not-found', attachmentId: id, message: NO_SUCH_ATTACHMENT }
50
50
  }
51
51
 
52
52
  const file = await attachmentForModel(row, textCharsOf(getAssistantMode(session.kind || '')?.attachments))
@@ -144,7 +144,8 @@ export class ChatSession {
144
144
  * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
145
145
  *
146
146
  * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
147
- * `private` 참여자만 읽는다.
147
+ * `group` owner 가 들인 사람과 owner 만 읽고 말한다(2026-10-24, ADR-0073 결정 3).
148
+ * `private` owner 혼자. 아무도 앉히지 않고, 그룹을 비공개로 바꾸면 들인 사람이 나간다.
148
149
  *
149
150
  * 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한
150
151
  * 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을
@@ -156,7 +157,7 @@ export class ChatSession {
156
157
  * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
157
158
  */
158
159
  @Column({ type: 'varchar', length: 16, default: 'open' })
159
- @Field({ description: "Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only)." })
160
+ @Field({ description: "Who may read this conversation: 'open' (anyone in the domain), 'group' (the owner and the people the owner brought in) or 'private' (the owner alone)." })
160
161
  visibility!: string
161
162
 
162
163
  /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */