@things-factory/ai-assistant 10.1.207 → 10.1.210

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 (181) 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/setup-assistant-dock.ts +1 -0
  9. package/client/utils/assistant-session-actions.ts +3 -1
  10. package/client/utils/assistant-session-admin.ts +9 -2
  11. package/client/utils/assistant-session-controller.ts +1 -1
  12. package/client/utils/assistant-typing.test.ts +33 -0
  13. package/client/utils/assistant-typing.ts +53 -0
  14. package/dist-client/components/assistant-chat.d.ts +28 -0
  15. package/dist-client/components/assistant-chat.js +259 -8
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.js +12 -4
  18. package/dist-client/components/assistant-dock.js.map +1 -1
  19. package/dist-client/components/assistant-invite.d.ts +39 -0
  20. package/dist-client/components/assistant-invite.js +150 -0
  21. package/dist-client/components/assistant-invite.js.map +1 -0
  22. package/dist-client/components/assistant-session-manager.d.ts +5 -1
  23. package/dist-client/components/assistant-session-manager.js +40 -10
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/proposal-request.test.js +8 -0
  26. package/dist-client/components/proposal-request.test.js.map +1 -1
  27. package/dist-client/components/roster-identity.d.ts +0 -2
  28. package/dist-client/components/roster-identity.js +0 -7
  29. package/dist-client/components/roster-identity.js.map +1 -1
  30. package/dist-client/components/roster-identity.test.js +2 -13
  31. package/dist-client/components/roster-identity.test.js.map +1 -1
  32. package/dist-client/setup-assistant-dock.js +1 -0
  33. package/dist-client/setup-assistant-dock.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/dist-client/utils/assistant-session-actions.d.ts +1 -0
  36. package/dist-client/utils/assistant-session-actions.js +3 -1
  37. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  38. package/dist-client/utils/assistant-session-admin.d.ts +5 -2
  39. package/dist-client/utils/assistant-session-admin.js +3 -0
  40. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  41. package/dist-client/utils/assistant-session-controller.d.ts +1 -1
  42. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  43. package/dist-client/utils/assistant-typing.d.ts +20 -0
  44. package/dist-client/utils/assistant-typing.js +48 -0
  45. package/dist-client/utils/assistant-typing.js.map +1 -0
  46. package/dist-client/utils/assistant-typing.test.d.ts +1 -0
  47. package/dist-client/utils/assistant-typing.test.js +30 -0
  48. package/dist-client/utils/assistant-typing.test.js.map +1 -0
  49. package/dist-server/service/assistant-chat-resolver.js +5 -12
  50. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  51. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  52. package/dist-server/service/assistant-mode/index.js +4 -1
  53. package/dist-server/service/assistant-mode/index.js.map +1 -1
  54. package/dist-server/service/assistant-mode/mode-categories.js +9 -2
  55. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  56. package/dist-server/service/assistant-mode/screen-rules.d.ts +19 -0
  57. package/dist-server/service/assistant-mode/screen-rules.js +40 -6
  58. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  59. package/dist-server/service/chat-message/chat-message-room.js +3 -1
  60. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  61. package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
  62. package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
  63. package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
  65. package/dist-server/service/chat-message/chat-typing.js +40 -0
  66. package/dist-server/service/chat-message/chat-typing.js.map +1 -0
  67. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  68. package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
  69. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  70. package/dist-server/service/chat-session/attachment-tools.js +2 -2
  71. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  72. package/dist-server/service/chat-session/chat-session.d.ts +2 -1
  73. package/dist-server/service/chat-session/chat-session.js +1 -1
  74. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  75. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
  76. package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
  77. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
  78. package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
  79. package/dist-server/service/chat-session/session-activity-publish.js +5 -2
  80. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
  81. package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
  82. package/dist-server/service/chat-session/session-visibility.js +28 -3
  83. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  84. package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
  85. package/dist-server/service/chat-session/session-write-gate.js +1 -1
  86. package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
  87. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  88. package/dist-server/service/chat-session-participant/index.js +4 -1
  89. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  90. package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
  91. package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
  92. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  93. package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
  94. package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
  95. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
  96. package/dist-server/service/chat-session-participant/read-roster.js +5 -2
  97. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
  98. package/dist-server/service/chat-session-resolver.d.ts +27 -0
  99. package/dist-server/service/chat-session-resolver.js +226 -25
  100. package/dist-server/service/chat-session-resolver.js.map +1 -1
  101. package/dist-server/service/data-entry/entry-target.d.ts +19 -0
  102. package/dist-server/service/data-entry/entry-target.js +20 -5
  103. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  104. package/dist-server/service/data-entry/entry-tools.js +2 -2
  105. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  106. package/dist-server/service/data-entry/entry-update.d.ts +2 -1
  107. package/dist-server/service/data-entry/entry-update.js +5 -3
  108. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  109. package/dist-server/service/data-read/list-read.d.ts +10 -0
  110. package/dist-server/service/data-read/list-read.js +12 -0
  111. package/dist-server/service/data-read/list-read.js.map +1 -1
  112. package/dist-server/service/data-read/read-target.d.ts +7 -0
  113. package/dist-server/service/data-read/read-target.js +2 -2
  114. package/dist-server/service/data-read/read-target.js.map +1 -1
  115. package/dist-server/service/data-read/read-tools.js +1 -1
  116. package/dist-server/service/data-read/read-tools.js.map +1 -1
  117. package/dist-server/service/index.d.ts +4 -2
  118. package/dist-server/service/index.js +9 -1
  119. package/dist-server/service/index.js.map +1 -1
  120. package/dist-server/service/testing/conversation-script.d.ts +1 -1
  121. package/dist-server/service/testing/conversation-script.js.map +1 -1
  122. package/dist-server/service/testing/mode-conformance.js +4 -0
  123. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  124. package/dist-server/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +9 -9
  126. package/server/explicit-graphql-types.test.ts +1 -1
  127. package/server/service/assistant-chat-resolver.ts +7 -12
  128. package/server/service/assistant-mode/index.ts +1 -1
  129. package/server/service/assistant-mode/mode-categories.ts +11 -3
  130. package/server/service/assistant-mode/mode-front.test.ts +53 -1
  131. package/server/service/assistant-mode/resolve.test.ts +2 -1
  132. package/server/service/assistant-mode/screen-rules.test.ts +41 -3
  133. package/server/service/assistant-mode/screen-rules.ts +44 -5
  134. package/server/service/chat-message/chat-message-room.test.ts +7 -2
  135. package/server/service/chat-message/chat-message-room.ts +3 -1
  136. package/server/service/chat-message/chat-typing-subscription.ts +52 -0
  137. package/server/service/chat-message/chat-typing.test.ts +45 -0
  138. package/server/service/chat-message/chat-typing.ts +48 -0
  139. package/server/service/chat-message/fold-history-window.test.ts +1 -1
  140. package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
  141. package/server/service/chat-session/attachment-tool-result.ts +7 -0
  142. package/server/service/chat-session/attachment-tools.ts +3 -3
  143. package/server/service/chat-session/chat-session.ts +3 -2
  144. package/server/service/chat-session/group-room.test.ts +233 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +20 -0
  146. package/server/service/chat-session/session-activity-publish.ts +6 -1
  147. package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
  148. package/server/service/chat-session/session-visibility.test.ts +32 -1
  149. package/server/service/chat-session/session-visibility.ts +33 -4
  150. package/server/service/chat-session/session-write-gate.test.ts +7 -1
  151. package/server/service/chat-session/session-write-gate.ts +2 -2
  152. package/server/service/chat-session-participant/index.ts +2 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
  154. package/server/service/chat-session-participant/invite-notification.ts +43 -5
  155. package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
  156. package/server/service/chat-session-participant/mention-invite.ts +82 -28
  157. package/server/service/chat-session-participant/read-roster.test.ts +13 -4
  158. package/server/service/chat-session-participant/read-roster.ts +6 -3
  159. package/server/service/chat-session-resolver.ts +204 -27
  160. package/server/service/data-entry/data-entry-update.test.ts +19 -0
  161. package/server/service/data-entry/data-entry.test.ts +65 -1
  162. package/server/service/data-entry/entry-target.ts +36 -5
  163. package/server/service/data-entry/entry-tools.ts +2 -2
  164. package/server/service/data-entry/entry-update.ts +6 -3
  165. package/server/service/data-read/list-missing.test.ts +49 -0
  166. package/server/service/data-read/list-read.ts +18 -0
  167. package/server/service/data-read/read-target.ts +10 -3
  168. package/server/service/data-read/read-tools.ts +1 -1
  169. package/server/service/data-read/record-keys.test.ts +36 -0
  170. package/server/service/index.ts +6 -1
  171. package/server/service/testing/conversation-script.ts +1 -1
  172. package/server/service/testing/mode-conformance.ts +2 -0
  173. package/test/assistant-dock.test.ts +5 -1
  174. package/test/assistant-session-manager.test.ts +4 -2
  175. package/test/open-doors.test.ts +10 -5
  176. package/translations/en.json +29 -4
  177. package/translations/es.json +29 -4
  178. package/translations/ja.json +29 -4
  179. package/translations/ko.json +30 -5
  180. package/translations/ms.json +29 -4
  181. package/translations/zh.json +29 -4
@@ -2,7 +2,7 @@
2
2
  * Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
3
3
  * and what the server asks again before it writes.
4
4
  */
5
- import { offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
5
+ import { readFieldValue, recordIdOfKey, resolveReferences, offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
6
6
  import { entryCategoryOf, proposeEntries } from './entry-tools'
7
7
  import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
8
8
  import { modeToolCategories } from '../assistant-mode/resolve'
@@ -410,3 +410,67 @@ describe("a moment is the domain's wall time", () => {
410
410
  expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
411
411
  })
412
412
  })
413
+
414
+ /*
415
+ * 2026-10-23: an office link card took `tax-invoice:<uuid>` - the key the link evidence and the open record show - and
416
+ * looked it up as an approval number. A reference that names a record of an axis reads that axis's key as its id.
417
+ */
418
+ describe('a reference names the record the model was shown', () => {
419
+ const ID = 'fe0e8503-ec5f-4da4-9c07-2d342c93d498'
420
+ const invoice = {
421
+ name: 'invoice',
422
+ type: 'reference' as const,
423
+ axis: 'tax-invoice',
424
+ reference: {
425
+ query: 'query ($text: String) { invoices(text: $text) { items { id approvalNo } } }',
426
+ variables: (text: string) => ({ text }),
427
+ recordsOf: (data: any, text: string) => (data?.invoices?.items ?? []).filter((one: any) => one.id === text || one.approvalNo === text)
428
+ }
429
+ }
430
+ const asked: string[] = []
431
+ const run = async (_query: string, variables: any) => {
432
+ asked.push(variables.text)
433
+ return { data: { invoices: { items: [{ id: ID, approvalNo: '20260120-10260120-17151515' }] } } }
434
+ }
435
+ const resolved = async (text: string, field: any = invoice) => {
436
+ const [row] = await resolveReferences([field], [{ values: { invoice: { text } }, issues: [] as any[] }], run)
437
+ return row.issues.length ? row.issues[0].code : row.values.invoice.id
438
+ }
439
+ beforeEach(() => (asked.length = 0))
440
+
441
+ it('★ the key `<axis>:<uuid>`, the bare uuid and the record\'s own words all find it', async () => {
442
+ expect(await resolved(`tax-invoice:${ID}`)).toBe(ID)
443
+ expect(asked).toEqual([ID])
444
+ expect(await resolved(ID)).toBe(ID)
445
+ expect(await resolved('20260120-10260120-17151515')).toBe(ID)
446
+ })
447
+
448
+ it('★ the key of a record of another axis is not this record, whatever its uuid', async () => {
449
+ expect(await resolved(`cash-transaction:${ID}`)).toBe('reference-not-found')
450
+ })
451
+
452
+ it('a reference with no axis reads what was written, as before', async () => {
453
+ const { axis, ...plain } = invoice
454
+ expect(await resolved(`tax-invoice:${ID}`, plain)).toBe('reference-not-found')
455
+ expect(asked).toEqual([`tax-invoice:${ID}`])
456
+ })
457
+
458
+ it('★ a field that holds a record\'s id reads its key as the id - one axis or any of several', () => {
459
+ const question = { name: 'questionId', type: 'string' as const, axis: 'strategy-question' }
460
+ expect(readFieldValue(question, ` strategy-question:${ID} `)).toEqual({ read: ID })
461
+ expect(readFieldValue(question, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
462
+ const owner = { name: 'ownerId', type: 'string' as const, axis: ['strategy-question', 'initiative'] }
463
+ expect(readFieldValue(owner, `initiative:${ID}`)).toEqual({ read: ID })
464
+ expect(readFieldValue({ name: 'note', type: 'string' }, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
465
+ /* A model that hands a resolved reference the key, not the id. */
466
+ expect(readFieldValue(invoice, { id: `tax-invoice:${ID}` })).toEqual({ read: { id: ID } })
467
+ })
468
+
469
+ it('a key names an id only when it is one', () => {
470
+ expect(recordIdOfKey('contract', `contract:${ID}`)).toBe(ID)
471
+ expect(recordIdOfKey('contract', ID.toUpperCase())).toBe(ID.toUpperCase())
472
+ expect(recordIdOfKey('contract', 'contract:H2026012')).toBeUndefined()
473
+ expect(recordIdOfKey('contract', 'H2026012')).toBeUndefined()
474
+ expect(recordIdOfKey('contract', `contract-round:${ID}`)).toBeUndefined()
475
+ })
476
+ })
@@ -36,6 +36,14 @@ export interface EntryField {
36
36
  * taken; none or several keep the row out and say so - the server never picks among several.
37
37
  */
38
38
  reference?: EntryReference
39
+ /**
40
+ * The axis of the record the field names (`tax-invoice`, `contract`) - several when it may name a record of each - for a
41
+ * record the model is shown by its key: an open record's anchor and an application's link evidence write
42
+ * `<axis>:<id>`. The key is read as the id it names, so what the model was shown is what it may write (2026-10-23: a
43
+ * card took `tax-invoice:<uuid>` from the evidence, looked it up as an approval number and found nothing). A reference
44
+ * reads it before its query runs; a string field holding an id reads it as the id.
45
+ */
46
+ axis?: string | readonly string[]
39
47
  }
40
48
 
41
49
  export interface EntryReference {
@@ -46,6 +54,22 @@ export interface EntryReference {
46
54
  recordsOf(data: any, text: string): { id: string; label?: string }[]
47
55
  }
48
56
 
57
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
58
+
59
+ /**
60
+ * The id a record's key names - `<axis>:<uuid>` as an anchor writes it, or the uuid alone. Nothing for a word, or for
61
+ * the key of a record of another axis: that one is not this record, whatever its uuid.
62
+ */
63
+ export function recordIdOfKey(axis: string | readonly string[], text: string): string | undefined {
64
+ const value = String(text ?? '').trim()
65
+ const prefix = (typeof axis === 'string' ? [axis] : axis).map(one => `${one}:`).find(one => value.startsWith(one))
66
+ const id = prefix ? value.slice(prefix.length) : value
67
+ return UUID.test(id) ? id : undefined
68
+ }
69
+
70
+ /** What the model wrote for a field that names a record - the id, when it wrote the record's key. */
71
+ const shownId = (field: EntryField, text: string): string => (field.axis && recordIdOfKey(field.axis, text)) || text
72
+
49
73
  /** A resolved reference, as a row keeps it. */
50
74
  export interface ReferenceValue {
51
75
  id: string
@@ -116,6 +140,12 @@ export interface EntryEvent {
116
140
  export interface EntryTarget {
117
141
  /** `<app>.<record>` - `office.expense-line`. */
118
142
  key: string
143
+ /**
144
+ * The records the card is about besides its own (the axes of `subject` anchors) - a link card is about the deposit,
145
+ * the invoice and the round it joins. On those records' screens its propose tool is put in front (ADR-0061 decision
146
+ * 9); asked to link a deposit from its 360, the model found the invoice and never opened the card (2026-10-24).
147
+ */
148
+ axes?: string[]
119
149
  /** i18n key of the record's name on the card. */
120
150
  labelKey: string
121
151
  /** What one row is, for the model. */
@@ -381,11 +411,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
381
411
  return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
382
412
  case 'reference':
383
413
  /* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
384
- if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
414
+ if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: shownId(field, (value as any).id), ...((value as any).label ? { label: String((value as any).label) } : {}) } }
385
415
  if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
386
416
  return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
387
417
  default:
388
- return { read: String(value).trim() }
418
+ return { read: shownId(field, String(value).trim()) }
389
419
  }
390
420
  }
391
421
 
@@ -651,11 +681,12 @@ export async function resolveReferences<T extends { values: Record<string, unkno
651
681
  for (const field of references) {
652
682
  const value: any = row.values[field.name]
653
683
  if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
654
- const key = `${field.name}\u0000${value.text}`
684
+ const text = shownId(field, value.text)
685
+ const key = `${field.name}\u0000${text}`
655
686
  if (!asked.has(key)) {
656
687
  try {
657
- const answer = await run(field.reference!.query, field.reference!.variables(value.text))
658
- asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
688
+ const answer = await run(field.reference!.query, field.reference!.variables(text))
689
+ asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, text) ?? [])
659
690
  } catch (error: any) {
660
691
  asked.set(key, error instanceof Error ? error : new Error(String(error)))
661
692
  }
@@ -153,7 +153,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
153
153
  doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
154
154
  description:
155
155
  `Propose changes to existing records: ${target.description} ` +
156
- 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
156
+ 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen), the id a list read or a find tool returned, or the record\'s key (`<kind>:<id>`) as you were shown it - nothing need be selected to change rows a read found. ' +
157
157
  'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
158
158
  schema: {
159
159
  type: 'object',
@@ -163,7 +163,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
163
163
  items: {
164
164
  type: 'object',
165
165
  properties: {
166
- ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
166
+ ref: { type: 'string', description: '"current", "selected", a record id from a list read or a find tool, or the record\'s key as shown - never a guessed id.' },
167
167
  values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
168
168
  },
169
169
  required: ['ref', 'values']
@@ -13,6 +13,7 @@ import {
13
13
  changeToolName,
14
14
  findToolName,
15
15
  readFieldValue,
16
+ recordIdOfKey,
16
17
  resolveReferences,
17
18
  zonedDateTimes,
18
19
  sameFieldValue,
@@ -52,7 +53,8 @@ export function changeableFields(target: EntryTarget): EntryField[] {
52
53
  /**
53
54
  * The records the changes name. `current` is the record on screen, `selected` every row selected there -
54
55
  * both from the surface's host context, which is **not trusted**: an id from it is only a candidate, read
55
- * through the application before anything is shown. Any other ref is an id, from the find tool.
56
+ * through the application before anything is shown. Any other ref is an id, from the find tool, or the key the record
57
+ * was shown by.
56
58
  */
57
59
  export function resolveChangeRefs(
58
60
  targetKey: string,
@@ -74,7 +76,8 @@ export function resolveChangeRefs(
74
76
  if (ids.length) for (const id of ids) picked.push({ id, via: 'selected', values })
75
77
  else unresolved.push('selected')
76
78
  } else if (ref) {
77
- picked.push({ id: ref, via: 'id', values })
79
+ /* An open record's key - `contract-round:<id>` for `office.contract-round` - names the record of this target's axis. */
80
+ picked.push({ id: recordIdOfKey(targetKey.split('.').pop()!, ref) ?? ref, via: 'id', values })
78
81
  } else {
79
82
  unresolved.push('no-ref')
80
83
  }
@@ -143,7 +146,7 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
143
146
  message:
144
147
  unresolved[0] === 'current' || unresolved[0] === 'selected'
145
148
  ? `the screen names no ${unresolved[0] === 'current' ? 'record' : 'selected rows'} of this kind. Ask the person which record, or call ${findToolName(target.key)}.`
146
- : `name each record: ref "current", "selected", or an id from ${findToolName(target.key)}. Never guess an id.`
149
+ : `name each record: ref "current", "selected", or an id a list read or ${findToolName(target.key)} returned. Never guess an id.`
147
150
  }
148
151
  }
149
152
  let records: Map<string, EntryRecord>
@@ -0,0 +1,49 @@
1
+ /*
2
+ * A list read takes what a row is missing, as its screen's filters say it (2026-10-24) - asked to fill the bank lines'
3
+ * unregistered parties, the model had no condition for "unregistered" and searched the asker's own name.
4
+ */
5
+ import { listReadTarget } from './list-read'
6
+ import { filtersOf, readCriteria } from './read-target'
7
+
8
+ const lines = listReadTarget({
9
+ key: 'office.cash-transaction',
10
+ title: 'menu.cash-transaction-list',
11
+ name: 'bank lines',
12
+ listQuery: 'transactions',
13
+ selection: 'id amount counterpartyName',
14
+ columns: [{ name: 'amount', type: 'number' }],
15
+ missing: [
16
+ {
17
+ name: 'counterparty',
18
+ description: 'a party name and no registered counterparty',
19
+ filters: [
20
+ { name: 'counterpartyId', operator: 'is_null', value: true },
21
+ { name: 'counterpartyName', operator: 'is_not_null', value: true }
22
+ ]
23
+ },
24
+ { name: 'account', description: 'no account subject', filters: [{ name: 'accountId', operator: 'is_null', value: true }] }
25
+ ]
26
+ })
27
+
28
+ describe('what a row is missing', () => {
29
+ it('★ the condition asks with the screen\'s own filters', () => {
30
+ const { criteria } = readCriteria(lines, { missing: 'counterparty' })
31
+ expect(filtersOf(lines, criteria)).toEqual([
32
+ { name: 'counterpartyId', operator: 'is_null', value: true },
33
+ { name: 'counterpartyName', operator: 'is_not_null', value: true }
34
+ ])
35
+ expect(filtersOf(lines, readCriteria(lines, { missing: 'account' }).criteria)).toEqual([{ name: 'accountId', operator: 'is_null', value: true }])
36
+ })
37
+
38
+ it('★ the model is told each thing a row may miss, and nothing else is taken', () => {
39
+ const missing = lines.criteria.find(one => one.name === 'missing')!
40
+ expect(missing.values).toEqual(['counterparty', 'account'])
41
+ expect(missing.description).toMatch(/counterparty - a party name and no registered counterparty/)
42
+ expect(readCriteria(lines, { missing: 'phone' }).refused).toEqual([{ name: 'missing', code: 'not-in-list' }])
43
+ })
44
+
45
+ it('a list that declares nothing missing has no such condition', () => {
46
+ const { missing, ...plain } = { ...(lines as any), missing: undefined }
47
+ expect(listReadTarget({ key: 'office.x', title: 't', name: 'x', listQuery: 'xs', selection: 'id', columns: [] }).criteria.some(one => one.name === 'missing')).toBe(false)
48
+ })
49
+ })
@@ -33,6 +33,12 @@ export interface ListRead {
33
33
  route?: string
34
34
  /** The route has a URL-bound grid. Otherwise conditions are disclosed as not applied on its screen card. */
35
35
  screenQuery?: boolean
36
+ /**
37
+ * What a row may be missing - the screen's own "no counterparty", "no account subject" filters - the `missing`
38
+ * condition. Asked to fill the bank lines' unregistered parties, the model had no way to say "unregistered" and
39
+ * searched the asker's own name (2026-10-24).
40
+ */
41
+ missing?: { name: string; description: string; filters: ReadFilter[] }[]
36
42
  }
37
43
 
38
44
  /** The money column a minimum or maximum amount means - `amount` when there is one, else the first summed column. */
@@ -62,6 +68,18 @@ export function listReadTarget(list: ListRead): ReadTarget {
62
68
  ...(list.status
63
69
  ? [{ name: 'status', type: 'enum', values: list.status.values, labelKey: 'ai-assistant.read.status', filter: (v: any) => ({ name: list.status!.field, operator: 'eq', value: v }) }]
64
70
  : []),
71
+ ...(list.missing?.length
72
+ ? [
73
+ {
74
+ name: 'missing',
75
+ type: 'enum',
76
+ values: list.missing.map(one => one.name),
77
+ labelKey: 'ai-assistant.read.missing',
78
+ description: `the rows missing one thing: ${list.missing.map(one => `${one.name} - ${one.description}`).join('; ')}`,
79
+ filter: (v: any) => list.missing!.find(one => one.name === v)!.filters
80
+ }
81
+ ]
82
+ : []),
65
83
  ...(amount
66
84
  ? [
67
85
  { name: 'minAmount', type: 'number', labelKey: 'ai-assistant.read.min-amount', description: `at least this ${amount}${inCurrency}`, filter: (v: any) => ({ name: amount, operator: 'gte', value: v }) },
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
11
11
  */
12
- import { readFieldValue, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
12
+ import { readFieldValue, recordIdOfKey, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
13
13
  import { encodeListQuery } from '@operato/utils/list-query.js'
14
14
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
15
15
  import { parse } from 'graphql'
@@ -143,6 +143,8 @@ export interface DetailArg {
143
143
  values?: string[]
144
144
  description?: string
145
145
  required?: boolean
146
+ /** A record's id: the axis (or axes) of the record, whose key `<axis>:<id>` the model may have been shown (EntryField.axis). */
147
+ axis?: string | readonly string[]
146
148
  }
147
149
 
148
150
  /**
@@ -160,6 +162,11 @@ export interface DetailRead {
160
162
  /** One query with one root field. Its variables are the arguments - each declared in `args`. */
161
163
  query: string
162
164
  args?: DetailArg[]
165
+ /**
166
+ * The records the answer is about, whose screens put it in front (EntryTarget.axes) - the management check answers
167
+ * for any ledger record; asked on a deposit's 360 why it was in the check, the model never opened it (2026-10-24).
168
+ */
169
+ axes?: string[]
163
170
  }
164
171
 
165
172
  /** Characters of an answer handed to the model - a longer one is refused, and the model narrows it. */
@@ -200,8 +207,8 @@ export function detailReadOf(key: string | undefined): DetailRead | undefined {
200
207
  export function readCriteria(target: ReadTarget, args: any): { criteria: Record<string, unknown>; refused: { name: string; code: string }[] } {
201
208
  const criteria: Record<string, unknown> = {}
202
209
  const refused: { name: string; code: string }[] = []
203
- /* One record by its id - every target takes it (the id a list answer or a find tool gave). */
204
- if (typeof args?.id === 'string' && args.id.trim()) criteria.id = args.id.trim()
210
+ /* One record by its id - every target takes it (the id a list answer or a find tool gave, or the key `<axis>:<id>` an open record is shown by, the axis being the key's last part). */
211
+ if (typeof args?.id === 'string' && args.id.trim()) criteria.id = recordIdOfKey(target.key.split('.').pop()!, args.id) ?? args.id.trim()
205
212
  if (target.search && typeof args?.text === 'string' && args.text.trim()) criteria.text = args.text.trim()
206
213
  for (const criterion of target.criteria) {
207
214
  const value = args?.[criterion.name]
@@ -246,7 +246,7 @@ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRun
246
246
  if (arg.required) refused.push({ name: arg.name, code: 'required' })
247
247
  continue
248
248
  }
249
- const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values } as any, value)
249
+ const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values, axis: arg.axis } as any, value)
250
250
  if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
251
251
  else variables[arg.name] = read
252
252
  }
@@ -0,0 +1,36 @@
1
+ /*
2
+ * A read takes a record by the key it was shown by (2026-10-23) - `<axis>:<uuid>`, as an open record's anchor and an
3
+ * application's evidence write it. A 360 read and a list read asked by id took the key as the id and found nothing.
4
+ */
5
+ import { readCriteria, type DetailRead, type ReadTarget } from './read-target'
6
+ import { queryDetail } from './read-tools'
7
+
8
+ const ID = 'f92c04ec-40c4-4be8-8160-080cff20e750'
9
+
10
+ describe('a read names the record the model was shown', () => {
11
+ const lines = { key: 'office.cash-transaction', criteria: [] } as unknown as ReadTarget
12
+
13
+ it('★ a list read by id takes the key of its own axis - the target key\'s last part', () => {
14
+ expect(readCriteria(lines, { id: `cash-transaction:${ID}` }).criteria).toEqual({ id: ID })
15
+ expect(readCriteria(lines, { id: ID }).criteria).toEqual({ id: ID })
16
+ /* Another record's key is not this record - asked as written, it finds nothing. */
17
+ expect(readCriteria(lines, { id: `contract:${ID}` }).criteria).toEqual({ id: `contract:${ID}` })
18
+ })
19
+
20
+ it('★ a whole answer takes the key of the axis its id argument declares', async () => {
21
+ const summary: DetailRead = {
22
+ key: 'office.contract-360',
23
+ description: "one contract's 360.",
24
+ query: 'query ($id: String!) { contract360(id: $id) { code } }',
25
+ args: [{ name: 'id', type: 'string', required: true, axis: 'contract' }]
26
+ }
27
+ const asked: any[] = []
28
+ const run = async (_document: string, variables: any) => {
29
+ asked.push(variables)
30
+ return { data: { contract360: { code: 'H2026012' } } }
31
+ }
32
+ await queryDetail(summary, { id: `contract:${ID}` }, run)
33
+ await queryDetail(summary, { id: `cash-transaction:${ID}` }, run)
34
+ expect(asked).toEqual([{ id: ID }, { id: `cash-transaction:${ID}` }])
35
+ })
36
+ })
@@ -5,6 +5,7 @@ import { AssistantCapabilityResolver } from './assistant-capability-resolver.js'
5
5
  import { AiAgentResolver } from './ai-agent-resolver.js'
6
6
  import { MySessionActivitySubscription } from './chat-session/session-activity-subscription.js'
7
7
  import { ChatMessageSubscription } from './chat-message/chat-message-subscription.js'
8
+ import { ChatTypingSubscription } from './chat-message/chat-typing-subscription.js'
8
9
  import { entities as ChatSessionEntities } from './chat-session/index.js'
9
10
  import { entities as ChatMessageEntities } from './chat-message/index.js'
10
11
  import { entities as ChatSessionParticipantEntities } from './chat-session-participant/index.js'
@@ -29,7 +30,9 @@ export * from './assistant-capability-resolver.js'
29
30
  export * from './tool-access.js'
30
31
  /* Data entry from material - an app registers what it can enter; a mode names it (design/ai/data-entry.md). */
31
32
  export * from './data-entry/entry-target.js'
32
- export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
33
+ export { registerEntryTarget, entryCategoryOf, runAsCaller } from './data-entry/entry-tools.js'
34
+ /* An application's tool that prepares many changes at once makes the same change card the model's would (2026-10-24). */
35
+ export { proposeChanges, MAX_CHANGES } from './data-entry/entry-update.js'
33
36
  /* Reading an application's lists - an app registers what it lets read; a mode names it (design/ai/read-targets.md). */
34
37
  export * from './data-read/read-target.js'
35
38
  export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf } from './data-read/read-tools.js'
@@ -79,6 +82,8 @@ export const schema = {
79
82
  /* A domain's AI agents - the settings card and the dock's picker (design/ai/agent-settings.md). */
80
83
  AiAgentResolver,
81
84
  ChatMessageSubscription,
85
+ /* 「입력 중」 — 저장하지 않는 방의 한 줄. 듣는 사람은 메시지와 같은 방 판정을 지난다. */
86
+ ChatTypingSubscription,
82
87
  /* 내가 참여한 모든 세션의 활동 — 창을 닫아 둔 표면이 배지·목록을 갱신할 수 있게. */
83
88
  MySessionActivitySubscription,
84
89
  /* 사람이 누른 입력 제안을 쓰는 자리 — 행마다 앱의 코드로, 그 사람의 권한으로. */
@@ -126,7 +126,7 @@ export interface ConversationOptions {
126
126
  */
127
127
  participants?: ScriptParticipant[]
128
128
  /** 이 대화가 공개인가 — 기본은 공개(ADR-0073 판정 2). 'private' 이면 멘션이 사람을 앉히지 않는다. */
129
- visibility?: 'open' | 'private'
129
+ visibility?: 'open' | 'group' | 'private'
130
130
  /** Who the assistant is on this surface — in real use it comes from the registered mode. */
131
131
  systemPrompt?: string
132
132
  /** Which registered categories this conversation may use. Omitted allows none. */
@@ -145,6 +145,8 @@ export function modeCarriesSharedRules(kind: string): string[] {
145
145
  const found: string[] = []
146
146
  if (/name our plumbing|do not describe our machinery/i.test(prompt)) found.push('absence-rule-copy')
147
147
  if (/^\s*Next:/im.test(prompt) || /end the reply with one last line/i.test(prompt)) found.push('next-request-rule-copy')
148
+ if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
149
+ if (/\[writer → addressee\]/.test(prompt)) found.push('speaker-rule-copy')
148
150
  return found
149
151
  }
150
152
 
@@ -168,7 +168,11 @@ describe('what twin needed of the dock', () => {
168
168
  })
169
169
 
170
170
  it('a carried conversation that could not be made is said, not swallowed', () => {
171
- assert.match(dock, /this\.startCarried\(ask\)\.catch\(error => \{\s*notify\(\{ level: 'error'/)
171
+ assert.match(dock, /this\.startCarried\(ask\)\.catch\(\(?\w*\)? => \{\s*notify\(\{ level: 'error'/)
172
+ })
173
+
174
+ it('★ and nothing is sent into the previous conversation after it - the request depended on the context that failed', () => {
175
+ assert.match(dock, /this\.startCarried\(ask\)\.catch\([\s\S]*?return undefined\s*\}\)[\s\S]{0,200}?if \(!id\) return\n/)
172
176
  })
173
177
  })
174
178
 
@@ -58,11 +58,13 @@ describe('the doors are the framework ones, as they are', () => {
58
58
  })
59
59
 
60
60
  describe('nothing irreversible without asking', () => {
61
- it('delete and making public ask first with the platform confirm; making private, hiding and renaming do not', () => {
61
+ it('delete, making public, making private (its people leave) and leaving ask first with the platform confirm; making it a group, hiding and renaming do not', () => {
62
62
  assert.match(manager, /import \{ OxPrompt \} from '@operato\/popup'/)
63
63
  assert.match(manager, /this\.askRemove\(entry, name\)/)
64
64
  assert.match(manager, /this\.askOpen\(entry, name\)/)
65
- assert.match(manager, /@select=\$\{\(\) => this\.setVisibility\(entry, 'private'\)\}/)
65
+ assert.match(manager, /this\.askPrivate\(entry, name\)/)
66
+ assert.match(manager, /this\.askLeave\(entry, name\)/)
67
+ assert.match(manager, /@select=\$\{\(\) => this\.setVisibility\(entry, 'group'\)\}/)
66
68
  })
67
69
 
68
70
  it('no browser dialog - they sit outside the theme', () => {
@@ -55,12 +55,15 @@ const OPEN_BY_DECISION: Record<Root, Record<string, string>> = {
55
55
  Mutation: {},
56
56
  Subscription: {
57
57
  chatMessageActivity:
58
- "subscribe: a member of the domain, and the room open to the caller — open to the domain, private to its participants " +
58
+ "subscribe: a member of the domain, and the room open to the caller — open to the domain, group or private to its seats " +
59
59
  '(ADR-0073 decision 2), refused with the words of a missing session otherwise. Every message: same domain + session, ' +
60
60
  'and the room asked again. The body is redacted by doors for each receiver (ADR-0073 ruling 1).',
61
61
  mySessionActivity:
62
62
  "subscribe: a member of the domain. Every message: same domain, and the caller among the payload's participants. " +
63
- 'Badge fields and a one-line preview only, no body.'
63
+ 'Badge fields and a one-line preview only, no body.',
64
+ chatTyping:
65
+ 'subscribe: a member of the domain, and the room open to the caller, asked as chatMessageActivity asks it. Every notice: ' +
66
+ "same domain + session, not the caller's own, and the room asked again. A user id, a name and a time - nothing stored, no body."
64
67
  }
65
68
  }
66
69
 
@@ -129,7 +132,7 @@ describe('what the walk itself has to be true for', () => {
129
132
  /* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
130
133
  expect(fieldsOf('Query').length).toBeGreaterThanOrEqual(10)
131
134
  expect(fieldsOf('Mutation').length).toBeGreaterThanOrEqual(13)
132
- expect(fieldsOf('Subscription')).toHaveLength(2)
135
+ expect(fieldsOf('Subscription')).toHaveLength(3)
133
136
  })
134
137
 
135
138
  it('tells an undeclared field from one the schema does not have', () => {
@@ -145,11 +148,13 @@ describe('what the walk itself has to be true for', () => {
145
148
  * 2026-09-30: 30 — applyEntryProposal (enter the rows the person pressed, design/ai/data-entry.md), declared.
146
149
  * 2026-10-12: 35 — aiAgentSettings · aiAgentChoices · aiProviderModels · saveAiAgents · aiUsageSummary (a domain's AI
147
150
  * agents and their usage, design/ai/agent-settings.md), declared.
151
+ * 2026-10-24: 41 — mayInviteToAISession · inviteToAISession · removeFromAISession · leaveAISession · sayTyping declared, chatTyping open (a
152
+ * group conversation and its typing line, ADR-0073 decision 3).
148
153
  */
149
154
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
150
155
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
151
156
 
152
- expect(all).toBe(35)
153
- expect(open).toBe(2)
157
+ expect(all).toBe(41)
158
+ expect(open).toBe(3)
154
159
  })
155
160
  })
@@ -1,4 +1,5 @@
1
1
  {
2
+ "ai-assistant.dock.carry-failed": "The selected context could not be attached. Nothing was sent. Try again from the original screen.",
2
3
  "ai-assistant.dock.wait-before-switch": "Wait for the current response before changing conversations.",
3
4
  "ai-assistant.dock.switch-title": "Change conversation?",
4
5
  "ai-assistant.dock.switch-draft": "Your unsent text and attachments will not move to the other conversation.",
@@ -14,7 +15,7 @@
14
15
  "ai-assistant.session.reset-filters": "Clear search and filters",
15
16
  "ai-assistant.session.filtered-empty": "No conversations match these filters here.",
16
17
  "ai-assistant.session.scope-empty": "No conversations yet.",
17
- "ai-assistant.session.audience-private": "Participants only",
18
+ "ai-assistant.session.audience-private": "Only me",
18
19
  "ai-assistant.session.audience-open": "Everyone in this domain",
19
20
  "ai-assistant.session.manage": "Manage conversation",
20
21
  "ai-assistant.session.list": "Conversations",
@@ -121,7 +122,6 @@
121
122
  "ai-assistant.text.say-to-room-tooltip": "say this to the people here — the assistant does not answer",
122
123
  "ai-assistant.text.no-room-yet": "this conversation is not saved yet, so there is nobody else in it to talk to",
123
124
  "ai-assistant.text.will-be-invited": "{names} will join this conversation when you send",
124
- "ai-assistant.text.cannot-invite": "{names} will not join — only the people already in this conversation can bring someone in",
125
125
  "ai-assistant.text.answer-needs-permission": "you do not have permission to see this answer. ask an administrator for access.",
126
126
  "ai-assistant.label.conversation": "conversation",
127
127
  "ai-assistant.text.switch-conversation": "switch to {name}",
@@ -182,7 +182,7 @@
182
182
  "ai-assistant.session.filter-whose": "Whose",
183
183
  "ai-assistant.session.filter-participant": "I'm in",
184
184
  "ai-assistant.session.filter-visibility": "Visibility",
185
- "ai-assistant.session.filter-any-visibility": "Public and private",
185
+ "ai-assistant.session.filter-any-visibility": "Any audience",
186
186
  "ai-assistant.session.filter-open": "Public",
187
187
  "ai-assistant.session.filter-private": "Private",
188
188
  "ai-assistant.session.filter-unread": "Unread",
@@ -247,6 +247,7 @@
247
247
  "ai-assistant.read.year": "year",
248
248
  "ai-assistant.read.month": "month",
249
249
  "ai-assistant.read.flow": "in or out",
250
+ "ai-assistant.read.missing": "missing",
250
251
  "ai-assistant.read.period": "month",
251
252
  "ai-assistant.read.status": "status",
252
253
  "ai-assistant.read.min-amount": "minimum amount",
@@ -399,5 +400,29 @@
399
400
  "ai-assistant.run.assistant-run-model-error": "the AI model failed during the run.",
400
401
  "ai-assistant.run.assistant-run-abandoned": "the run stopped partway and was started again.",
401
402
  "ai-assistant.run.ai-not-configured": "AI is not set up for this domain.",
402
- "ai-assistant.run.ai-agent-not-available": "the AI agent picked for this run is not available here."
403
+ "ai-assistant.run.ai-agent-not-available": "the AI agent picked for this run is not available here.",
404
+ "ai-assistant.text.cannot-invite-private": "{names} will not join — a private conversation is yours alone. Make it a group to bring people in",
405
+ "ai-assistant.text.cannot-invite-group": "{names} will not join — only the person who started this group can bring someone in",
406
+ "ai-assistant.button.invite": "Invite",
407
+ "ai-assistant.label.participants": "People in this conversation",
408
+ "ai-assistant.button.remove-participant": "Send {name} out",
409
+ "ai-assistant.text.invited": "{name} can now read and write here, and has been told.",
410
+ "ai-assistant.text.removed-participant": "{name} is no longer in this conversation.",
411
+ "ai-assistant.text.typing-many": "{count} people are typing",
412
+ "ai-assistant.text.typing": "Typing: {names}",
413
+ "ai-assistant.text.invite-search": "Name or email",
414
+ "ai-assistant.text.invite-none-found": "No one to invite by that name.",
415
+ "ai-assistant.session.filter-group": "Group",
416
+ "ai-assistant.session.make-group": "Make it a group",
417
+ "ai-assistant.session.leave": "Leave",
418
+ "ai-assistant.session.confirm-private": "Make \"{name}\" private? Only you will read it, and everyone else in it leaves.",
419
+ "ai-assistant.session.confirm-leave-open": "Leave \"{name}\"? Its new messages stop reaching you.",
420
+ "ai-assistant.session.confirm-leave": "Leave \"{name}\"? You will not read it again unless its owner brings you back.",
421
+ "ai-assistant.session.leave-refused": "you could not leave this conversation.",
422
+ "ai-assistant.session.audience-group": "Invited people",
423
+ "ai-assistant.text.invite-title": "Conversation invite",
424
+ "ai-assistant.text.invite-body-named": "{who} brought you into the conversation \"{name}\".",
425
+ "ai-assistant.text.invite-body": "{who} brought you into a conversation.",
426
+ "ai-assistant.text.board-invite-title": "Board collaboration invite",
427
+ "ai-assistant.text.board-invite-body": "{who} invited you to collaborate on a board."
403
428
  }