@things-factory/ai-assistant 10.1.47 → 10.1.50

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 (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -0
  9. package/client/setup-assistant-dock.ts +109 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +110 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +118 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.47",
3
+ "version": "10.1.50",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -26,16 +26,23 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@apollo/client": "^3.13.0",
29
+ "@material/web": "^2.0.0",
29
30
  "@operato/graphql": "^10.0.0",
30
31
  "@operato/i18n": "^10.0.0",
32
+ "@operato/layout": "^10.0.0",
33
+ "@operato/popup": "^10.16.0",
34
+ "@operato/shell": "^10.0.0",
31
35
  "@operato/styles": "^10.0.0",
32
36
  "@things-factory/ai-client-base": "^10.1.39",
37
+ "@things-factory/apptool-base": "^10.1.47",
38
+ "@things-factory/attachment-base": "^10.1.47",
33
39
  "@things-factory/auth-base": "^10.1.47",
34
40
  "@things-factory/env": "^10.1.20",
35
41
  "@things-factory/shell": "^10.1.47",
36
42
  "dompurify": "^3.0.0",
37
43
  "graphql-scalars": "^1.22.0",
38
44
  "graphql-tag": "^2.12.6",
45
+ "graphql-upload": "^15.0.2",
39
46
  "lit": "^3.1.2",
40
47
  "marked": "^15.0.0"
41
48
  },
@@ -44,5 +51,5 @@
44
51
  "copyfiles": "^2.4.1",
45
52
  "rimraf": "^5.0.0"
46
53
  },
47
- "gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
54
+ "gitHead": "4700d39d70eb5fae4ad56cf47ea0b966c8516ae2"
48
55
  }
@@ -53,6 +53,11 @@ import { judgeSessionKind } from './chat-session/session-kind.js'
53
53
  import { readerGate } from './chat-message/reader-gate.js'
54
54
  import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
55
55
  import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
56
+ import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
57
+ import { assertMayWriteSession } from './chat-session/session-write-gate.js'
58
+ import { attachmentHistoryLine, refuseAttachments } from './chat-session/attachment-policy.js'
59
+ import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
60
+ import { getAssistantMode } from './assistant-mode/registry.js'
56
61
  import { resolveModeSettings } from './assistant-mode/resolve.js'
57
62
  import { requestTranslator } from './request-translator.js'
58
63
 
@@ -121,6 +126,12 @@ class AssistantChatInput {
121
126
  })
122
127
  truncateAfterMessageId?: string
123
128
 
129
+ @Field(() => [String], {
130
+ nullable: true,
131
+ description: 'Files stored with this conversation by attachToConversation, sent with this turn.'
132
+ })
133
+ attachmentIds?: string[]
134
+
124
135
  }
125
136
 
126
137
  @ObjectType()
@@ -225,6 +236,16 @@ export class AssistantChatResolver {
225
236
  if (input.sessionId) {
226
237
  const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
227
238
  if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
239
+ /*
240
+ * A private conversation is written by its people only - and here more than anywhere: the answer is
241
+ * built on this conversation's history, so an outsider's question would read it back to them.
242
+ */
243
+ await assertMayWriteSession({
244
+ session: found as any,
245
+ userId: user?.id,
246
+ isParticipant: () => isSessionParticipant(domain.id, found.id!, user?.id),
247
+ t: (key, options) => t(key, options as any)
248
+ })
228
249
  if (found.anchorType === 'figure' && input.hostContext?.figureId !== found.anchorId) {
229
250
  throw new Error(
230
251
  context.t('ai-assistant.text.other-figure-history', {
@@ -265,6 +286,26 @@ export class AssistantChatResolver {
265
286
  const lastUserMessage = input.messages[input.messages.length - 1]
266
287
  let userMessageId: string | undefined
267
288
 
289
+ /*
290
+ * Files sent with this turn (design/ai/shared-dock.md §9). They were stored with this conversation by
291
+ * attachToConversation; one that is not this conversation's is refused, never read. The policy is the
292
+ * mode's - checked again here, since the door is the authority, not the screen.
293
+ */
294
+ const attachmentRows: any[] = []
295
+ if (input.attachmentIds?.length) {
296
+ if (!session) throw new Error(t('ai-assistant.text.attachments-need-session', { defaultValue: 'attachments need a saved conversation.' }))
297
+ for (const id of input.attachmentIds) {
298
+ const row = await findChatAttachment(domain.id, session.id!, id)
299
+ if (!row) throw new Error(t('ai-assistant.text.attachment-not-in-session', { defaultValue: 'an attachment does not belong to this conversation.' }))
300
+ attachmentRows.push(row)
301
+ }
302
+ const refusal = refuseAttachments(
303
+ getAssistantMode(input.mode.kind)?.attachments,
304
+ attachmentRows.map(row => ({ name: row.name, type: row.mimetype, size: Number(row.size) }))
305
+ )
306
+ if (refusal) throw new Error(t(`ai-assistant.text.${refusal.code}`, { ...(refusal as any), defaultValue: refusal.code }))
307
+ }
308
+
268
309
  if (session && lastUserMessage?.role === 'user') {
269
310
  const previousLast = await messageRepo.findOne({
270
311
  where: { session: { id: session.id } as any },
@@ -280,10 +321,11 @@ export class AssistantChatResolver {
280
321
  * from the request — two sources for one fact can disagree.
281
322
  */
282
323
  addressee: 'assistant',
324
+ attachments: attachmentRows.length ? JSON.stringify(attachmentRows.map(describeAttachment)) : undefined,
283
325
  creator: user,
284
326
  updater: user,
285
327
  parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
286
- })
328
+ } as any)
287
329
  userMessageId = saved.id
288
330
 
289
331
  /*
@@ -312,7 +354,7 @@ export class AssistantChatResolver {
312
354
  visibility: (session as any).visibility
313
355
  })
314
356
  if (invite.invited.length > 0) {
315
- const payload = buildInviteNotification(invite.invited, undefined, user)
357
+ const payload = buildInviteNotification(invite.invited, undefined, user, (session as any)?.name)
316
358
  try {
317
359
  if (payload) await (context.state as any)?.notify?.(payload)
318
360
  } catch {
@@ -373,7 +415,14 @@ export class AssistantChatResolver {
373
415
  storedRows.map(message => ({
374
416
  id: message.id,
375
417
  role: message.role,
376
- content: message.content,
418
+ /*
419
+ * A file sent in an earlier turn is not sent again - one line names it, and the model opens it
420
+ * with readChatAttachment when it needs to (cost is a usage axis, ADR-0061 decision 6).
421
+ */
422
+ content:
423
+ message.id !== userMessageId && (message as any).attachmentsJson?.length
424
+ ? [message.content, ...(message as any).attachmentsJson.map(attachmentHistoryLine)].join('\n')
425
+ : message.content,
377
426
  senderId: (message as any).creator?.id,
378
427
  senderName: (message as any).creator?.name,
379
428
  addressee: (message as any).addressee
@@ -390,7 +439,25 @@ export class AssistantChatResolver {
390
439
  )
391
440
  : input.messages.map(message => ({ role: message.role as any, content: message.content }))
392
441
 
393
- if (input.hostContext?.image?.data && conversation.length > 0) {
442
+ if (attachmentRows.length && conversation.length > 0) {
443
+ const last = conversation[conversation.length - 1]
444
+ if (last.role === 'user' && typeof last.content === 'string') {
445
+ const parts: any[] = [{ type: 'text', text: last.content }]
446
+ for (const row of attachmentRows) {
447
+ const file = await attachmentForModel(row)
448
+ if (file.type === 'image') {
449
+ parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
450
+ } else {
451
+ parts.push({
452
+ type: 'text',
453
+ text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
454
+ })
455
+ }
456
+ }
457
+ last.content = parts as any
458
+ }
459
+ } else if (input.hostContext?.image?.data && conversation.length > 0) {
460
+ /* The screen-image path (figure's rendered view) - not a file of the conversation. */
394
461
  const last = conversation[conversation.length - 1]
395
462
  if (last.role === 'user' && typeof last.content === 'string') {
396
463
  const raw = input.hostContext.image
@@ -55,6 +55,12 @@ class AssistantModeView {
55
55
 
56
56
  @Field({ nullable: true, description: 'The line shown once the host reports a proposal staged.' })
57
57
  stagedNotice?: string
58
+
59
+ @Field(() => GraphQLJSON, {
60
+ nullable: true,
61
+ description: "What this conversation accepts as attachments: { kinds: ('image'|'text')[], maxBytes, maxFiles }. Null: none."
62
+ })
63
+ attachments?: any
58
64
  }
59
65
 
60
66
  @ObjectType({ description: 'A conversation a screen may open for what it is showing.' })
@@ -1,3 +1,4 @@
1
+ import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
1
2
  /**
2
3
  * What the door and the screen each take from a mode.
3
4
  *
@@ -51,7 +52,10 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
51
52
 
52
53
  return {
53
54
  systemPrompt: mode.systemPrompt,
54
- toolCategories: mode.toolCategories,
55
+ /* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
56
+ toolCategories: mode.attachments?.kinds?.length
57
+ ? [...(mode.toolCategories ?? []), 'chat-attachments']
58
+ : mode.toolCategories,
55
59
  requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
56
60
  model: mode.model,
57
61
  temperature: mode.temperature,
@@ -77,6 +81,8 @@ export interface AssistantModeView {
77
81
  examples?: Array<{ label: string; items: string[] }>
78
82
  footerNotice?: string
79
83
  stagedNotice?: string
84
+ /** What the screen may attach - the same policy the server enforces. */
85
+ attachments?: AttachmentPolicy
80
86
  }
81
87
 
82
88
  /** What the screen shows, in the words of the request's locale. */
@@ -100,6 +106,7 @@ export function assistantModeView(kind: string, t: Translate): AssistantModeView
100
106
  items: group.itemKeys.map(key => t(key))
101
107
  })),
102
108
  footerNotice: mode.footerNoticeKey ? t(mode.footerNoticeKey) : undefined,
103
- stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined
109
+ stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined,
110
+ attachments: mode.attachments
104
111
  }
105
112
  }
@@ -1,3 +1,4 @@
1
+ import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
1
2
  /**
2
3
  * What a conversation is, declared by the app server that owns the screen (ADR-0061 decision 7).
3
4
  *
@@ -94,6 +95,11 @@ export interface AssistantModeSpec {
94
95
  */
95
96
  exampleKeys?: Array<{ labelKey: string; itemKeys: string[] }>
96
97
 
98
+ /**
99
+ * What this conversation accepts as attachments (design/ai/shared-dock.md §9-1). Absent: nothing - the
100
+ * screen offers no attach button, so no file is taken that the model would never see.
101
+ */
102
+ attachments?: AttachmentPolicy
97
103
  /** i18n key for the line under an empty conversation. */
98
104
  footerNoticeKey?: string
99
105
  /** i18n key for the line shown once the host reports a proposal staged. */
@@ -160,6 +160,23 @@ export class ChatMessage {
160
160
  *
161
161
  * 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).
162
162
  */
163
+ /**
164
+ * The files sent with this turn - `[{ id, name, mimetype, size }]` as JSON (design/ai/shared-dock.md §9-2).
165
+ * The files themselves live in attachment-base under the conversation and are opened only through it.
166
+ */
167
+ @Column({ type: 'text', nullable: true })
168
+ attachments?: string
169
+
170
+ @Field(() => GraphQLJSON, { nullable: true, description: 'Files sent with this turn: [{ id, name, mimetype, size }].' })
171
+ get attachmentsJson(): any {
172
+ if (!this.attachments) return null
173
+ try {
174
+ return JSON.parse(this.attachments)
175
+ } catch {
176
+ return null
177
+ }
178
+ }
179
+
163
180
  @Column({ type: 'varchar', length: 1024, nullable: true })
164
181
  groundingWarnings?: string
165
182
 
@@ -0,0 +1,42 @@
1
+ /* What a conversation accepts as attachments - design/ai/shared-dock.md §9-1. */
2
+ import { attachmentHistoryLine, attachmentKindOf, refuseAttachments } from './attachment-policy'
3
+
4
+ describe('attachment policy', () => {
5
+ const policy = { kinds: ['image', 'text'] as any, maxBytes: 1000, maxFiles: 2 }
6
+
7
+ it('a mode that declares nothing takes nothing - no file the model would never see', () => {
8
+ expect(refuseAttachments(undefined, [{ name: 'a.png', type: 'image/png', size: 1 }])).toEqual({ code: 'attachments-not-accepted' })
9
+ expect(refuseAttachments({ kinds: [] }, [{ name: 'a.png', type: 'image/png' }])).toEqual({ code: 'attachments-not-accepted' })
10
+ })
11
+
12
+ it('no files is no refusal', () => {
13
+ expect(refuseAttachments(undefined, [])).toBeNull()
14
+ })
15
+
16
+ it('kinds come from the type, or the name when the browser did not know the type', () => {
17
+ expect(attachmentKindOf({ type: 'image/jpeg' })).toBe('image')
18
+ expect(attachmentKindOf({ type: 'text/csv' })).toBe('text')
19
+ expect(attachmentKindOf({ type: '', name: 'report.CSV' })).toBe('text')
20
+ expect(attachmentKindOf({ type: 'application/pdf', name: 'a.pdf' })).toBeNull()
21
+ })
22
+
23
+ it('each limit refuses by name, before sending', () => {
24
+ expect(refuseAttachments(policy, [{ name: 'a.pdf', type: 'application/pdf', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted', name: 'a.pdf' })
25
+ expect(refuseAttachments(policy, [{ name: 'big.png', type: 'image/png', size: 1001 }])).toMatchObject({ code: 'attachment-too-large', name: 'big.png', maxBytes: 1000 })
26
+ expect(refuseAttachments(policy, [{}, {}, {}] as any)).toEqual({ code: 'too-many-attachments', maxFiles: 2 })
27
+ })
28
+
29
+ it('a kind outside the declared ones is refused', () => {
30
+ expect(refuseAttachments({ kinds: ['image'] }, [{ name: 'a.csv', type: 'text/csv', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted' })
31
+ })
32
+
33
+ it('files within the policy go', () => {
34
+ expect(refuseAttachments(policy, [{ name: 'a.png', type: 'image/png', size: 10 }, { name: 'b.csv', type: 'text/csv', size: 10 }])).toBeNull()
35
+ })
36
+
37
+ it('the line an earlier turn keeps names the file and how to open it', () => {
38
+ expect(attachmentHistoryLine({ id: 'f1', name: 'press.csv', mimetype: 'text/csv', size: 120 })).toBe(
39
+ '[attachment f1: press.csv (text/csv, 120 bytes) - readChatAttachment to open it]'
40
+ )
41
+ })
42
+ })
@@ -0,0 +1,68 @@
1
+ /*
2
+ * What a conversation accepts as attachments - declared by its mode (design/ai/shared-dock.md §9-1).
3
+ *
4
+ * Pure, so the rule is tested alone and the screen can say "no" before anything is sent. A mode that
5
+ * declares nothing accepts nothing: the chat once took .csv and .json, carried them, and no code ever
6
+ * showed them to the model - the person got an answer about a file it had never seen (AI standard §7).
7
+ */
8
+
9
+ export type AttachmentKind = 'image' | 'text'
10
+
11
+ export interface AttachmentPolicy {
12
+ kinds: AttachmentKind[]
13
+ /** Per file. */
14
+ maxBytes?: number
15
+ /** Per message. */
16
+ maxFiles?: number
17
+ }
18
+
19
+ export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
20
+ export const DEFAULT_MAX_FILES = 5
21
+ /** How much of a text file reaches the model in the turn it was sent. */
22
+ export const TEXT_CHARS_FOR_MODEL = 20_000
23
+
24
+ const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
25
+ const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
26
+
27
+ /** Which kind a file is, from its type - and its name when the browser did not know the type. */
28
+ export function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {
29
+ const type = (file.type || '').toLowerCase()
30
+ if (type.startsWith('image/')) return 'image'
31
+ if (TEXT_TYPES.includes(type)) return 'text'
32
+ const name = (file.name || '').toLowerCase()
33
+ if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'
34
+ return null
35
+ }
36
+
37
+ export type AttachmentRefusal =
38
+ | { code: 'attachments-not-accepted' }
39
+ | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }
40
+ | { code: 'attachment-too-large'; name: string; maxBytes: number }
41
+ | { code: 'too-many-attachments'; maxFiles: number }
42
+
43
+ /** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */
44
+ export function refuseAttachments(
45
+ policy: AttachmentPolicy | null | undefined,
46
+ files: { name?: string; type?: string; size?: number }[]
47
+ ): AttachmentRefusal | null {
48
+ if (!files.length) return null
49
+ if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
50
+
51
+ const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
52
+ if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
53
+
54
+ const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
55
+ for (const file of files) {
56
+ const kind = attachmentKindOf(file)
57
+ if (!kind || !policy.kinds.includes(kind)) {
58
+ return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
59
+ }
60
+ if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** The line history keeps for a file once its turn is past - the model reads the file again only by asking. */
66
+ export function attachmentHistoryLine(file: { id: string; name?: string; mimetype?: string; size?: number | string }): string {
67
+ return `[attachment ${file.id}: ${file.name || 'unnamed'} (${file.mimetype || 'unknown type'}, ${file.size ?? '?'} bytes) - readChatAttachment to open it]`
68
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Where a person opens a conversation's file - GET /chat-attachments/:sessionId/:attachmentId.
3
+ *
4
+ * Domain-private (sign-in and tenancy required), and the conversation's own read gate on top: a private
5
+ * conversation's file opens for its participants only, a deleted conversation's for nobody (design/ai/
6
+ * shared-dock.md §9-3). attachment-base's own route is public and unauthenticated, so chat files are
7
+ * never linked through it. `private` caching - a shared cache must not keep a private conversation's file.
8
+ */
9
+ import { STORAGE } from '@things-factory/attachment-base'
10
+ import { getRepository } from '@things-factory/shell'
11
+
12
+ import { ChatSession } from './chat-session.js'
13
+ import { mayReadSession } from './session-visibility.js'
14
+ import { findChatAttachment } from './session-attachments.js'
15
+ import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
16
+
17
+ export const CHAT_ATTACHMENT_ROUTE = 'chat-attachments'
18
+
19
+ /** The address a screen uses for one file of a conversation. */
20
+ export function chatAttachmentUrl(sessionId: string, attachmentId: string): string {
21
+ return `/${CHAT_ATTACHMENT_ROUTE}/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`
22
+ }
23
+
24
+ process.on('bootstrap-module-domain-private-route' as any, (_app: any, routes: any) => {
25
+ routes.get(`/${CHAT_ATTACHMENT_ROUTE}/:sessionId/:attachmentId`, async (context: any, next: any) => {
26
+ const { domain, user } = context.state
27
+ const { sessionId, attachmentId } = context.params
28
+
29
+ /* A soft-deleted conversation is not found here - its files close with it (§9-5). */
30
+ const session: any = await getRepository(ChatSession).findOne({ where: { id: sessionId, domain: { id: domain.id } } as any })
31
+ const readable = !!session && mayReadSession(session.visibility, await isSessionParticipant(domain.id, sessionId, user?.id))
32
+ const row: any = readable ? await findChatAttachment(domain.id, sessionId, attachmentId) : undefined
33
+
34
+ if (!row) {
35
+ /* One answer for "none", "not yours" and "deleted" - telling them apart would say what exists. */
36
+ context.status = 404
37
+ return
38
+ }
39
+
40
+ context.set('Cache-Control', 'private, max-age=3600')
41
+ context.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(row.name)}`)
42
+ if (row.mimetype) context.type = row.mimetype
43
+ await STORAGE.sendFile(context, row.path, next)
44
+ })
45
+ })
@@ -0,0 +1,63 @@
1
+ /*
2
+ * readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).
3
+ *
4
+ * A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.
5
+ * When the model needs it again it asks here. The caller must be able to read the conversation the file
6
+ * belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere
7
+ * opens nothing.
8
+ *
9
+ * The category is opened for every mode that declares an attachment policy (resolve.ts).
10
+ */
11
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
12
+ import { Attachment } from '@things-factory/attachment-base'
13
+ import { getRepository } from '@things-factory/shell'
14
+
15
+ import { ChatSession } from './chat-session.js'
16
+ import { mayReadSession } from './session-visibility.js'
17
+ import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
18
+ import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
+
20
+ export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
21
+
22
+ const readChatAttachment: ToolSpec = {
23
+ kind: 'read',
24
+ name: 'readChatAttachment',
25
+ doors: ['Query.chatMessages'],
26
+ description:
27
+ 'Open a file that was sent earlier in this conversation. History names such files as "[attachment <id>: <name> ...]"; ' +
28
+ 'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',
29
+ schema: {
30
+ type: 'object',
31
+ properties: { attachmentId: { type: 'string', description: 'The id from the "[attachment <id>: ...]" line.' } },
32
+ required: ['attachmentId']
33
+ },
34
+ builder: async (args: any, context: any) => {
35
+ const { domain, user } = context?.state ?? {}
36
+ const id = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : ''
37
+ if (!domain?.id || !id) return { absent: 'no-attachment', message: 'no attachment id was given.' }
38
+
39
+ const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
40
+ const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
41
+ if (!row || !session) return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
42
+
43
+ const participant = await isSessionParticipant(domain.id, session.id, user?.id)
44
+ if (!mayReadSession(session.visibility, participant)) {
45
+ /* Same answer as absent - saying "it exists but is private" would itself leak. */
46
+ return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
47
+ }
48
+
49
+ const file = await attachmentForModel(row)
50
+ return file.type === 'image'
51
+ ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
52
+ : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
53
+ }
54
+ } as ToolSpec
55
+
56
+ export const chatAttachmentCategory: ToolCategory = {
57
+ name: CHAT_ATTACHMENT_CATEGORY,
58
+ guidance:
59
+ '- A line "[attachment <id>: <name> ...]" names a file sent in an earlier turn. Call readChatAttachment with that id before answering about its contents; never guess what a file says.',
60
+ specs: [readChatAttachment]
61
+ }
62
+
63
+ registerToolCategory(chatAttachmentCategory)
@@ -0,0 +1,89 @@
1
+ /*
2
+ * A conversation's files - stored in attachment-base, opened only through the conversation.
3
+ *
4
+ * Files are kept with `refType: 'chat-session'`, `refBy: <session id>` (design/ai/shared-dock.md §9-2).
5
+ * They are never handed out through attachment-base's own route: that route is public and unauthenticated
6
+ * (routes.ts, "TODO make this secure"), so a private conversation's file would leave the conversation.
7
+ * Every read here passes the conversation's gate first (§9-3); this module only does the storage part.
8
+ */
9
+ import { Attachment, STORAGE, createAttachment } from '@things-factory/attachment-base'
10
+ import { getRepository } from '@things-factory/shell'
11
+
12
+ import { attachmentKindOf, TEXT_CHARS_FOR_MODEL } from './attachment-policy.js'
13
+
14
+ export const CHAT_ATTACHMENT_REF = 'chat-session'
15
+
16
+ export interface StoredChatAttachment {
17
+ id: string
18
+ name: string
19
+ mimetype: string
20
+ size: number
21
+ }
22
+
23
+ export function describeAttachment(row: any): StoredChatAttachment {
24
+ return { id: row.id, name: row.name, mimetype: row.mimetype, size: Number(row.size) || 0 }
25
+ }
26
+
27
+ /** Stores uploaded files under the conversation. The caller has passed the write gate and the mode's policy. */
28
+ export async function saveChatAttachments(sessionId: string, files: any[], context: any): Promise<StoredChatAttachment[]> {
29
+ const saved: StoredChatAttachment[] = []
30
+ for (const file of files) {
31
+ const row = await createAttachment(null, { attachment: { file, category: 'chat', refType: CHAT_ATTACHMENT_REF, refBy: sessionId } }, context)
32
+ saved.push(describeAttachment(row))
33
+ }
34
+ return saved
35
+ }
36
+
37
+ /** One file of the conversation - `undefined` when it is not this conversation's. */
38
+ export async function findChatAttachment(domainId: string, sessionId: string, attachmentId: string): Promise<any | undefined> {
39
+ return (
40
+ (await getRepository(Attachment).findOne({
41
+ where: { id: attachmentId, domain: { id: domainId }, refType: CHAT_ATTACHMENT_REF, refBy: sessionId } as any
42
+ })) ?? undefined
43
+ )
44
+ }
45
+
46
+ /**
47
+ * What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,
48
+ * and the cut is said), anything else as a line that names it.
49
+ */
50
+ export async function attachmentForModel(row: any): Promise<
51
+ | { type: 'image'; data: Buffer; mediaType: string; name: string }
52
+ | { type: 'text'; text: string; name: string; truncated: boolean }
53
+ > {
54
+ const kind = attachmentKindOf({ type: row.mimetype, name: row.name })
55
+ if (kind === 'image') {
56
+ const data = await STORAGE.readFile(row.path)
57
+ return { type: 'image', data: Buffer.isBuffer(data) ? data : Buffer.from(data), mediaType: row.mimetype, name: row.name }
58
+ }
59
+ const content = String(await STORAGE.readFile(row.path, 'utf8'))
60
+ const truncated = content.length > TEXT_CHARS_FOR_MODEL
61
+ return { type: 'text', text: truncated ? content.slice(0, TEXT_CHARS_FOR_MODEL) : content, name: row.name, truncated }
62
+ }
63
+
64
+ /** Removes these files from storage and their rows. */
65
+ export async function removeAttachmentRows(ids: string[]): Promise<void> {
66
+ if (!ids.length) return
67
+ const rows = await getRepository(Attachment).find({ where: ids.map(id => ({ id, refType: CHAT_ATTACHMENT_REF })) as any })
68
+ for (const row of rows) {
69
+ try {
70
+ await STORAGE.deleteFile((row as any).path)
71
+ } catch {
72
+ /* already gone from storage */
73
+ }
74
+ }
75
+ if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))
76
+ }
77
+
78
+ /** Removes a conversation's files from storage and their rows - the purge's part (§9-5). */
79
+ export async function removeSessionFiles(session: { id?: string; domain?: { id?: string } }): Promise<void> {
80
+ const rows = await getRepository(Attachment).find({ where: { refType: CHAT_ATTACHMENT_REF, refBy: session.id } as any })
81
+ for (const row of rows) {
82
+ try {
83
+ await STORAGE.deleteFile((row as any).path)
84
+ } catch {
85
+ /* A file already gone from storage must not keep its row alive. */
86
+ }
87
+ }
88
+ if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))
89
+ }
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * 집계 쿼리를 쓰지 않는 설계라(5개 DB 드라이버) 판정이 전부 값 비교로 끝난다 — 그래서 순수 테스트가 된다.
13
13
  */
14
- import { buildInbox, countUnreadSessions, activityPatch, applyHidden, searchInbox } from './session-inbox'
14
+ import { buildInbox, countUnreadSessions, activityPatch, applyHidden, searchInbox, filterInbox } from './session-inbox'
15
15
 
16
16
  const t = (iso: string) => new Date(iso)
17
17
 
@@ -201,3 +201,73 @@ describe('preview carries its doors', () => {
201
201
  expect(activityPatch('이것 좀 봐줘').lastMessageDoors).toEqual([])
202
202
  })
203
203
  })
204
+
205
+ describe('visibility in the inbox (ADR-0073)', () => {
206
+ const sessions = [
207
+ { id: 'open-mine', visibility: 'open', lastMessageAt: '2026-09-24T10:00:00Z' },
208
+ { id: 'private-mine', visibility: 'private', lastMessageAt: '2026-09-24T09:00:00Z' },
209
+ { id: 'private-theirs', visibility: 'private', lastMessageAt: '2026-09-24T08:00:00Z' },
210
+ { id: 'legacy', lastMessageAt: '2026-09-24T07:00:00Z' }
211
+ ]
212
+ const mine = [
213
+ { sessionId: 'open-mine', role: 'owner' },
214
+ { sessionId: 'private-mine', role: 'member' }
215
+ ]
216
+
217
+ it("someone else's private conversation does not appear in the list at all", () => {
218
+ const ids = buildInbox(sessions as any, mine as any).map(entry => entry.id)
219
+ expect(ids).toEqual(['open-mine', 'private-mine', 'legacy'])
220
+ })
221
+
222
+ it('each entry says its visibility; a row from before the column reads as open', () => {
223
+ const byId = Object.fromEntries(buildInbox(sessions as any, mine as any).map(entry => [entry.id, entry]))
224
+ expect(byId['open-mine'].visibility).toBe('open')
225
+ expect(byId['private-mine'].visibility).toBe('private')
226
+ expect(byId['legacy'].visibility).toBe('open')
227
+ })
228
+
229
+ it('only the owner row says owner - the one who may change visibility', () => {
230
+ const byId = Object.fromEntries(buildInbox(sessions as any, mine as any).map(entry => [entry.id, entry]))
231
+ expect(byId['open-mine'].owner).toBe(true)
232
+ expect(byId['private-mine'].owner).toBe(false)
233
+ expect(byId['legacy'].owner).toBe(false)
234
+ })
235
+ })
236
+
237
+
238
+ describe('the manager filters (shared dock §2-6)', () => {
239
+ const E = (id: string, extra: any = {}) => ({ id, participant: false, unread: false, visibility: 'open', owner: false, ...extra })
240
+ const entries = [
241
+ E('a', { kind: 'ecs.control', mine: true, participant: true, unread: true }),
242
+ E('b', { kind: 'ecs.board', participant: true, visibility: 'private' }),
243
+ E('c', { kind: 'ecs.control' }),
244
+ E('legacy', {})
245
+ ]
246
+ const ids = (filter: any) => filterInbox(entries as any, filter).map(e => e.id)
247
+
248
+ it('no filter shows every conversation on the anchor', () => {
249
+ expect(ids({})).toEqual(['a', 'b', 'c', 'legacy'])
250
+ })
251
+
252
+ it('by kind - a session from before the kind column stays under every kind', () => {
253
+ expect(ids({ kind: 'ecs.control' })).toEqual(['a', 'c', 'legacy'])
254
+ })
255
+
256
+ it('whose - started by me, or the ones I am in', () => {
257
+ expect(ids({ mineOnly: true })).toEqual(['a'])
258
+ expect(ids({ participantOnly: true })).toEqual(['a', 'b'])
259
+ })
260
+
261
+ it('visibility - a row without the column reads as open', () => {
262
+ expect(ids({ visibility: 'private' })).toEqual(['b'])
263
+ expect(ids({ visibility: 'open' })).toEqual(['a', 'c', 'legacy'])
264
+ })
265
+
266
+ it('unread only', () => {
267
+ expect(ids({ unreadOnly: true })).toEqual(['a'])
268
+ })
269
+
270
+ it('filters combine', () => {
271
+ expect(ids({ kind: 'ecs.control', participantOnly: true })).toEqual(['a'])
272
+ })
273
+ })