@things-factory/ai-assistant 10.1.212 → 10.1.214

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 (127) hide show
  1. package/client/components/assistant-chat.ts +115 -2
  2. package/client/components/assistant-dock.ts +106 -3
  3. package/client/components/assistant-selection-bar.test.ts +42 -0
  4. package/client/components/assistant-selection-bar.ts +129 -0
  5. package/client/components/workspace-view.ts +56 -3
  6. package/client/index.ts +5 -0
  7. package/client/utils/assistant-ask.test.ts +38 -0
  8. package/client/utils/assistant-ask.ts +35 -1
  9. package/client/utils/assistant-proposals.ts +23 -0
  10. package/client/utils/assistant-selection.test.ts +54 -0
  11. package/client/utils/assistant-selection.ts +74 -0
  12. package/client/utils/assistant-session-controller.ts +2 -0
  13. package/client/utils/assistant-session-transport.ts +1 -1
  14. package/client/utils/invite-proposal-register.ts +31 -0
  15. package/client/utils/invite-proposal.test.ts +40 -0
  16. package/client/utils/invite-proposal.ts +54 -0
  17. package/dist-client/components/assistant-chat.d.ts +18 -0
  18. package/dist-client/components/assistant-chat.js +117 -2
  19. package/dist-client/components/assistant-chat.js.map +1 -1
  20. package/dist-client/components/assistant-dock.d.ts +11 -0
  21. package/dist-client/components/assistant-dock.js +97 -4
  22. package/dist-client/components/assistant-dock.js.map +1 -1
  23. package/dist-client/components/assistant-selection-bar.d.ts +44 -0
  24. package/dist-client/components/assistant-selection-bar.js +149 -0
  25. package/dist-client/components/assistant-selection-bar.js.map +1 -0
  26. package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
  27. package/dist-client/components/assistant-selection-bar.test.js +38 -0
  28. package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
  29. package/dist-client/components/workspace-view.d.ts +2 -0
  30. package/dist-client/components/workspace-view.js +58 -3
  31. package/dist-client/components/workspace-view.js.map +1 -1
  32. package/dist-client/index.d.ts +3 -0
  33. package/dist-client/index.js +5 -0
  34. package/dist-client/index.js.map +1 -1
  35. package/dist-client/tsconfig.tsbuildinfo +1 -1
  36. package/dist-client/utils/assistant-ask.d.ts +22 -1
  37. package/dist-client/utils/assistant-ask.js +19 -0
  38. package/dist-client/utils/assistant-ask.js.map +1 -1
  39. package/dist-client/utils/assistant-ask.test.d.ts +1 -0
  40. package/dist-client/utils/assistant-ask.test.js +40 -0
  41. package/dist-client/utils/assistant-ask.test.js.map +1 -0
  42. package/dist-client/utils/assistant-proposals.d.ts +14 -0
  43. package/dist-client/utils/assistant-proposals.js +7 -0
  44. package/dist-client/utils/assistant-proposals.js.map +1 -1
  45. package/dist-client/utils/assistant-selection.d.ts +43 -0
  46. package/dist-client/utils/assistant-selection.js +59 -0
  47. package/dist-client/utils/assistant-selection.js.map +1 -0
  48. package/dist-client/utils/assistant-selection.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-selection.test.js +46 -0
  50. package/dist-client/utils/assistant-selection.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  52. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  53. package/dist-client/utils/assistant-session-transport.js +1 -1
  54. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  55. package/dist-client/utils/invite-proposal-register.d.ts +1 -0
  56. package/dist-client/utils/invite-proposal-register.js +24 -0
  57. package/dist-client/utils/invite-proposal-register.js.map +1 -0
  58. package/dist-client/utils/invite-proposal.d.ts +17 -0
  59. package/dist-client/utils/invite-proposal.js +37 -0
  60. package/dist-client/utils/invite-proposal.js.map +1 -0
  61. package/dist-client/utils/invite-proposal.test.d.ts +1 -0
  62. package/dist-client/utils/invite-proposal.test.js +35 -0
  63. package/dist-client/utils/invite-proposal.test.js.map +1 -0
  64. package/dist-server/service/assistant-chat-resolver.js +7 -3
  65. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  66. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  67. package/dist-server/service/assistant-mode/index.js +6 -1
  68. package/dist-server/service/assistant-mode/index.js.map +1 -1
  69. package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -1
  70. package/dist-server/service/assistant-mode/mode-categories.js +57 -5
  71. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  72. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  73. package/dist-server/service/assistant-mode/screen-rules.js +21 -6
  74. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  75. package/dist-server/service/data-read/read-target.d.ts +24 -3
  76. package/dist-server/service/data-read/read-target.js +42 -10
  77. package/dist-server/service/data-read/read-target.js.map +1 -1
  78. package/dist-server/service/data-read/read-tools.d.ts +18 -1
  79. package/dist-server/service/data-read/read-tools.js +19 -5
  80. package/dist-server/service/data-read/read-tools.js.map +1 -1
  81. package/dist-server/service/index.d.ts +5 -1
  82. package/dist-server/service/index.js +10 -1
  83. package/dist-server/service/index.js.map +1 -1
  84. package/dist-server/service/people/index.d.ts +4 -0
  85. package/dist-server/service/people/index.js +22 -0
  86. package/dist-server/service/people/index.js.map +1 -0
  87. package/dist-server/service/people/invite-tools.d.ts +34 -0
  88. package/dist-server/service/people/invite-tools.js +144 -0
  89. package/dist-server/service/people/invite-tools.js.map +1 -0
  90. package/dist-server/service/people/people-keys.d.ts +8 -0
  91. package/dist-server/service/people/people-keys.js +16 -0
  92. package/dist-server/service/people/people-keys.js.map +1 -0
  93. package/dist-server/service/people/people-read.d.ts +4 -0
  94. package/dist-server/service/people/people-read.js +57 -0
  95. package/dist-server/service/people/people-read.js.map +1 -0
  96. package/dist-server/service/testing/mode-conformance.js +3 -0
  97. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  98. package/dist-server/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +6 -6
  100. package/server/service/assistant-chat-resolver.ts +8 -4
  101. package/server/service/assistant-mode/conversation-category.test.ts +35 -0
  102. package/server/service/assistant-mode/index.ts +3 -1
  103. package/server/service/assistant-mode/mode-categories.ts +72 -5
  104. package/server/service/assistant-mode/mode-front.test.ts +32 -4
  105. package/server/service/assistant-mode/resolve.test.ts +1 -1
  106. package/server/service/assistant-mode/screen-rules.test.ts +23 -1
  107. package/server/service/assistant-mode/screen-rules.ts +22 -5
  108. package/server/service/data-entry/data-entry.test.ts +2 -2
  109. package/server/service/data-read/detail-lists.test.ts +61 -0
  110. package/server/service/data-read/read-target.ts +54 -9
  111. package/server/service/data-read/read-tools.ts +32 -6
  112. package/server/service/index.ts +7 -1
  113. package/server/service/people/index.ts +13 -0
  114. package/server/service/people/invite-tools.ts +159 -0
  115. package/server/service/people/invite.test.ts +159 -0
  116. package/server/service/people/people-keys.ts +16 -0
  117. package/server/service/people/people-read.test.ts +82 -0
  118. package/server/service/people/people-read.ts +56 -0
  119. package/server/service/testing/mode-conformance.test.ts +14 -1
  120. package/server/service/testing/mode-conformance.ts +2 -0
  121. package/test/assistant-dock.test.ts +73 -0
  122. package/translations/en.json +25 -1
  123. package/translations/es.json +25 -1
  124. package/translations/ja.json +25 -1
  125. package/translations/ko.json +25 -1
  126. package/translations/ms.json +25 -1
  127. package/translations/zh.json +25 -1
@@ -236,8 +236,21 @@ function argSchema(arg: DetailArg): any {
236
236
  return { type, ...(said ? { description: said } : {}) }
237
237
  }
238
238
 
239
- /** Runs the query as the person with the arguments the model set; the answer as the query returned it. */
240
- export async function queryDetail(target: DetailRead, args: any, run: GraphqlRunner) {
239
+ /**
240
+ * What running a detail read as the person gave - the root field's value, or why there is none. Shared by the tool,
241
+ * which hands the value to the model, and by every other reader of a detail read (a dashboard card, ADR-0160
242
+ * decision 2), which has its own limits: the arguments, the door and the query are one, the size rule is the tool's.
243
+ */
244
+ export interface DetailRun {
245
+ /** The root field's value - absent when `error` says why. */
246
+ value?: unknown
247
+ error?: 'argument-unreadable' | 'query-failed' | 'not-found'
248
+ refused?: { name: string; code: string }[]
249
+ message?: string
250
+ }
251
+
252
+ /** Runs the query as the person with the arguments given - read by their declared types, a missing required one refused. */
253
+ export async function runDetail(target: DetailRead, args: any, run: GraphqlRunner): Promise<DetailRun> {
241
254
  const variables: Record<string, unknown> = {}
242
255
  const refused: { name: string; code: string }[] = []
243
256
  for (const arg of target.args ?? []) {
@@ -250,17 +263,30 @@ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRun
250
263
  if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
251
264
  else variables[arg.name] = read
252
265
  }
253
- if (refused.length) return { rejected: true, error: 'argument-unreadable', refused, message: `these arguments are missing or do not fit their type: ${refused.map(one => `${one.name} (${one.code})`).join(', ')}. Fix them and call again.` }
266
+ if (refused.length) return { error: 'argument-unreadable', refused }
254
267
  let answer
255
268
  try {
256
269
  answer = await run(target.query, variables)
257
270
  } catch (error: any) {
258
- return { rejected: true, error: 'query-failed', message: `it could not be read: ${error?.message ?? error}` }
271
+ return { error: 'query-failed', message: String(error?.message ?? error) }
259
272
  }
260
- if (answer.errors?.length) return { rejected: true, error: 'query-failed', message: `it could not be read: ${answer.errors.map(one => one.message).join('; ')}` }
273
+ if (answer.errors?.length) return { error: 'query-failed', message: answer.errors.map(one => one.message).join('; ') }
261
274
  const root = rootFieldOf(target.query, 'query').replace(/^Query\./, '')
262
275
  const value = answer.data?.[root]
263
- if (value === null || value === undefined) return { target: target.key, found: false, message: 'nothing was found for these arguments - say so; do not fill it in.' }
276
+ if (value === null || value === undefined) return { error: 'not-found' }
277
+ return { value }
278
+ }
279
+
280
+ /** Runs the query as the person with the arguments the model set; the answer as the query returned it. */
281
+ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRunner) {
282
+ const ran = await runDetail(target, args, run)
283
+ if (ran.error) {
284
+ if (ran.error === 'argument-unreadable')
285
+ return { rejected: true, error: 'argument-unreadable', refused: ran.refused, message: `these arguments are missing or do not fit their type: ${ran.refused.map(one => `${one.name} (${one.code})`).join(', ')}. Fix them and call again.` }
286
+ if (ran.error === 'query-failed') return { rejected: true, error: 'query-failed', message: `it could not be read: ${ran.message}` }
287
+ return { target: target.key, found: false, message: 'nothing was found for these arguments - say so; do not fill it in.' }
288
+ }
289
+ const value = ran.value
264
290
  const size = JSON.stringify(value).length
265
291
  if (size > MAX_DETAIL_CHARS) return { rejected: true, error: 'answer-too-large', size, message: `the answer runs to ${size} characters, more than ${MAX_DETAIL_CHARS} - narrow the arguments, or read the list it is made of.` }
266
292
  return { target: target.key, answer: value, message: 'answer from these fields; every number you say comes from them. Amounts are in the currency beside them - never add two currencies into one number.' }
@@ -14,6 +14,8 @@ import './chat-session/session-purge.js'
14
14
  import './chat-session/attachment-route.js'
15
15
  import { EntryProposalResolver } from './data-entry/entry-resolver.js'
16
16
  import './workspace/workspace-tools.js'
17
+ /* Who works here, and the card that brings one of them into a conversation - every conversation carries both. */
18
+ import './people/index.js'
17
19
  import { entities as UnattendedRunEntities } from './unattended-run/index.js'
18
20
 
19
21
  export * from './types.js'
@@ -35,8 +37,9 @@ export { registerEntryTarget, entryCategoryOf, runAsCaller } from './data-entry/
35
37
  export { proposeChanges, MAX_CHANGES } from './data-entry/entry-update.js'
36
38
  /* Reading an application's lists - an app registers what it lets read; a mode names it (design/ai/read-targets.md). */
37
39
  export * from './data-read/read-target.js'
38
- export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf } from './data-read/read-tools.js'
40
+ export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf, runDetail, type DetailRun } from './data-read/read-tools.js'
39
41
  export * from './data-read/list-read.js'
42
+ export * from './people/index.js'
40
43
  /* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
41
44
  export * from './workspace/workspace-blocks.js'
42
45
  /* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
@@ -65,6 +68,9 @@ export * from './chat-session-participant/mention-invite.js'
65
68
  export * from './chat-session-participant/presence.js'
66
69
  export * from './chat-message/index.js'
67
70
  export * from './chat-session-participant/index.js'
71
+ /* Reading the stored proposals of a conversation and whether one is answered - a package that applies its own card reads them as entry does. */
72
+ export { PROPOSAL_OUTCOME_PREFIX } from './chat-message/proposal-outcome.js'
73
+ export { assertMayWriteSession } from './chat-session/session-write-gate.js'
68
74
  /* A mode run with no person in it - a scheduled draft (operato-application/design/ai/unattended-run.md). */
69
75
  export * from './unattended-run/index.js'
70
76
 
@@ -0,0 +1,13 @@
1
+ /*
2
+ * The people tools every conversation carries - the people list (people-read.ts) and the invite card (invite-tools.ts).
3
+ * Registered once, at boot, as the AI 작업판 is.
4
+ */
5
+ import { registerReadTarget } from '../data-read/read-tools.js'
6
+ import { peopleReadTarget } from './people-read.js'
7
+ import './invite-tools.js'
8
+
9
+ registerReadTarget(peopleReadTarget())
10
+
11
+ export * from './people-keys.js'
12
+ export { peopleReadTarget, PEOPLE_SELECTION } from './people-read.js'
13
+ export { proposeInvite, findPeopleNamed, inviteCategory, MAX_INVITE_CANDIDATES, type InviteCandidate } from './invite-tools.js'
@@ -0,0 +1,159 @@
1
+ /*
2
+ * proposeInvite - the model offers a card that brings someone into this conversation (ADR-0073 decision 3).
3
+ *
4
+ * The card is a proposal: the model seats nobody. What an assistant writes must not seat people (mention-invite.ts,
5
+ * rule ①), so the seat is given when a person presses the card - the dock calls `inviteToAISession` with that
6
+ * person's request, and the door asks `maySeat` with their seat. This tool only finds who is meant and says whether
7
+ * this person could press it here.
8
+ *
9
+ * Who is meant is the server's to settle, never the model's memory: the name is looked up in the organization's own
10
+ * list as the person (the read people-read.ts opens), so a user id the model made up is never on a card. One person
11
+ * by that name - the card invites them. Several - the card lists each and the person picks one; the model does not ask.
12
+ */
13
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
14
+ import { getRepository } from '@things-factory/shell'
15
+
16
+ import { runAsCaller } from '../data-entry/entry-tools.js'
17
+ import type { GraphqlRunner } from '../data-entry/entry-update.js'
18
+ import { ChatSession } from '../chat-session/chat-session.js'
19
+ import { maySeat } from '../chat-session/session-visibility.js'
20
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
21
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
22
+ import { PEOPLE_SELECTION } from './people-read.js'
23
+ import { INVITE_CATEGORY } from './people-keys.js'
24
+
25
+ /** At most this many people on one card - a name that matches more is too broad to pick from. */
26
+ export const MAX_INVITE_CANDIDATES = 8
27
+
28
+ export interface InviteCandidate {
29
+ userId: string
30
+ name: string
31
+ department?: string
32
+ position?: string
33
+ }
34
+
35
+ const PEOPLE_QUERY =
36
+ 'query ($filters: [Filter!], $pagination: Pagination, $search: String) ' +
37
+ `{ employees(filters: $filters, pagination: $pagination, search: $search) { items { ${PEOPLE_SELECTION} } total } }`
38
+
39
+ const ACTIVE = { name: 'active', operator: 'eq', value: true }
40
+
41
+ const candidateOf = (row: any): InviteCandidate | undefined =>
42
+ row?.user?.id
43
+ ? {
44
+ userId: String(row.user.id),
45
+ name: String(row.name ?? ''),
46
+ ...(row.department?.name ? { department: String(row.department.name) } : {}),
47
+ ...(row.jobPosition ? { position: String(row.jobPosition) } : {})
48
+ }
49
+ : undefined
50
+
51
+ /**
52
+ * The people a name means, as the person may see them: the exact name first, then the name as a search term. Only
53
+ * people with an account - an invite seats a user.
54
+ */
55
+ export async function findPeopleNamed(run: GraphqlRunner, name: string): Promise<{ people: InviteCandidate[]; error?: string }> {
56
+ const ask = async (variables: Record<string, unknown>) => {
57
+ const answer = await run(PEOPLE_QUERY, { pagination: { page: 1, limit: MAX_INVITE_CANDIDATES + 1 }, ...variables })
58
+ if (answer.errors?.length) return { error: answer.errors.map(one => one.message).join('; ') }
59
+ return { rows: (answer.data?.employees?.items ?? []) as any[] }
60
+ }
61
+ const exact = await ask({ filters: [ACTIVE, { name: 'name', operator: 'eq', value: name }] })
62
+ if ('error' in exact) return { people: [], error: exact.error }
63
+ const rows = exact.rows!.length ? exact.rows! : ((await ask({ filters: [ACTIVE], search: name })) as any).rows ?? []
64
+ return { people: rows.map(candidateOf).filter(Boolean) as InviteCandidate[] }
65
+ }
66
+
67
+ /** Why this person cannot bring anyone into this room, in their words - or nothing when they can. */
68
+ function seatRefusal(visibility: string | null | undefined, t: (key: string, options: any) => string): string | undefined {
69
+ if (visibility === 'private')
70
+ return t('ai-assistant.text.invite-private-refused', { defaultValue: 'a private conversation is yours alone. make it a group to bring people in.' })
71
+ return t('ai-assistant.text.invite-owner-only', { defaultValue: 'only the person who started this group conversation can bring people in.' })
72
+ }
73
+
74
+ export async function proposeInvite(args: any, context: any, run: GraphqlRunner = runAsCaller(context)) {
75
+ const t = (key: string, options: any) => (typeof context?.t === 'function' ? context.t(key, options) : String(options?.defaultValue ?? key))
76
+ const { domain, user } = context?.state ?? {}
77
+ const name = typeof args?.name === 'string' ? args.name.trim() : ''
78
+ const wanted = typeof args?.userId === 'string' ? args.userId.trim() : ''
79
+ if (!name) return { rejected: true, error: 'no-name', message: 'say whom to invite - the name the person used.' }
80
+
81
+ const conversationId = context?.conversation?.id
82
+ const session: any = conversationId && domain?.id ? await getRepository(ChatSession).findOne({ where: { id: conversationId, domain: { id: domain.id } } as any }) : undefined
83
+ if (!session) return { rejected: true, error: 'no-conversation', message: 'this conversation is not stored yet, so nobody can be brought into it.' }
84
+
85
+ /* Asked before anyone is looked up: the card must be one this person can press here. */
86
+ const seats = await getRepository(ChatSessionParticipant).find({ where: { domain: { id: domain.id }, session: { id: session.id } } as any })
87
+ const mine = seats.find((seat: any) => seat.userId === user?.id)?.role
88
+ if (!maySeat(session.visibility, mine)) {
89
+ /* Who reads the conversation is the person's to change, in the conversation list - the model has no tool for it (2026-10-24: it offered 「그룹 대화로 바꿔줘」). */
90
+ return {
91
+ rejected: true,
92
+ error: 'may-not-invite',
93
+ message: `${seatRefusal(session.visibility, t)} Say so in one line - do not offer a card. Who reads this conversation is changed by the person who started it, from the conversation list (⋯); you cannot change it, so never offer to or suggest asking you.`
94
+ }
95
+ }
96
+
97
+ const found = await findPeopleNamed(run, name)
98
+ if (found.error) return { rejected: true, error: 'people-unreadable', message: `the people list could not be read: ${found.error}` }
99
+ const here = new Set(seats.filter((seat: any) => isRoomRole(seat.role)).map((seat: any) => seat.userId))
100
+ const already = found.people.filter(one => here.has(one.userId))
101
+ let people = found.people.filter(one => !here.has(one.userId) && one.userId !== user?.id)
102
+ if (wanted) people = people.filter(one => one.userId === wanted)
103
+
104
+ if (!people.length) {
105
+ return already.length
106
+ ? { rejected: true, error: 'already-here', message: `${already.map(one => one.name).join(', ')} is already in this conversation.` }
107
+ : { rejected: true, error: 'not-found', message: `no one named "${name}" with an account is in the people list this person may see.` }
108
+ }
109
+ if (people.length > MAX_INVITE_CANDIDATES) {
110
+ return { rejected: true, error: 'too-many', message: `more than ${MAX_INVITE_CANDIDATES} people match "${name}" - ask for the full name or the department.` }
111
+ }
112
+
113
+ const one = people.length === 1 ? people[0] : undefined
114
+ return {
115
+ proposed: true,
116
+ kind: 'invite',
117
+ icon: 'person_add',
118
+ label: one
119
+ ? t('ai-assistant.people.invite-one', { name: one.name, defaultValue: 'Invite {name} to this conversation' })
120
+ : t('ai-assistant.people.invite-pick', { name, count: people.length, defaultValue: '{count} people named {name} - pick whom to invite' }),
121
+ ...(one && (one.department || one.position) ? { reason: [one.department, one.position].filter(Boolean).join(' · ') } : {}),
122
+ people,
123
+ message: one
124
+ ? `the card invites ${one.name}${one.department ? ` (${one.department})` : ''}. The person presses it; they are told when they join. Say that in one line.`
125
+ : `${people.length} people match "${name}"; the card lists each with department and position and the person picks one. Do not ask which - the card does.`
126
+ }
127
+ }
128
+
129
+ const spec = {
130
+ kind: 'read',
131
+ name: 'proposeInvite',
132
+ /* It reads people through the organization's list - the same door that list has, so it is offered where that list is. */
133
+ doors: ['Query.employees'],
134
+ description:
135
+ 'Offer a card that brings someone into this conversation. Give the name the person used; the server finds them in ' +
136
+ "the organization's people list and puts them on the card (several people by that name: the card lists each, the " +
137
+ 'person picks). The person presses the card - nobody joins until then. One call per person.',
138
+ schema: {
139
+ type: 'object',
140
+ properties: {
141
+ name: { type: 'string', description: 'The name of the person to invite, as the person said it.' },
142
+ userId: { type: 'string', description: 'The user id from the people list, when it was read already and the person meant that one.' }
143
+ },
144
+ required: ['name']
145
+ },
146
+ builder: (args: any, context: any) => proposeInvite(args, context)
147
+ } as ToolSpec
148
+
149
+ export const inviteCategory: ToolCategory = {
150
+ name: INVITE_CATEGORY,
151
+ description: 'bring someone into this conversation - a card the person presses',
152
+ guidance: [
153
+ '- When the person asks to invite, add or bring someone into this conversation, call proposeInvite with that name in the same turn. Never send them to a screen to do it.',
154
+ '- To answer who someone is or who works where, read the people list (queryOrganizationEmployee).'
155
+ ].join('\n'),
156
+ specs: [spec]
157
+ }
158
+
159
+ registerToolCategory(inviteCategory)
@@ -0,0 +1,159 @@
1
+ /*
2
+ * The invite card (people/invite-tools.ts) on sqlite - the room's rules are the stored ones, the people list is a
3
+ * stand-in for the organization's query as the person.
4
+ *
5
+ * Asked 「정연희 초대해 줘」 the model said it had no tool to look people up and sent the person to a screen
6
+ * (2026-10-24). Now the name is found in the people list and the card carries whom it means; the card itself seats
7
+ * nobody - a person pressing it does, through the invite door.
8
+ */
9
+ import type { DataSource } from 'typeorm'
10
+
11
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
12
+ const shell = require('@things-factory/shell')
13
+ const authBase = require('@things-factory/auth-base')
14
+
15
+ function loadCompiled(modulePath: string): any {
16
+ const { existsSync } = require('fs')
17
+ const { join } = require('path')
18
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', 'service', `${modulePath}.js`)
19
+ if (!existsSync(resolved)) throw new Error(`ai-assistant has no build output: ${resolved}\nBuild first:\n yarn workspace @things-factory/ai-assistant build:server`)
20
+ return require(resolved)
21
+ }
22
+ const { ChatSession } = loadCompiled('chat-session/chat-session')
23
+ const { ChatMessage } = loadCompiled('chat-message/chat-message')
24
+ const { ChatSessionParticipant } = loadCompiled('chat-session-participant/chat-session-participant')
25
+ const { proposeInvite, MAX_INVITE_CANDIDATES } = loadCompiled('people/invite-tools')
26
+
27
+ let db: DataSource
28
+ let domain: any
29
+ let owner: any
30
+ let member: any
31
+ let session: any
32
+ let asked: Array<{ document: string; variables: any }>
33
+
34
+ beforeAll(async () => {
35
+ db = new TypeOrmDataSource({
36
+ type: 'sqlite',
37
+ database: ':memory:',
38
+ synchronize: true,
39
+ dropSchema: true,
40
+ namingStrategy: new shell.NamingStrategy(),
41
+ entities: [...shell.entities, ...authBase.entities, ChatSession, ChatMessage, ChatSessionParticipant],
42
+ logging: false
43
+ })
44
+ await db.initialize()
45
+ shell.addDataSource('default', db)
46
+ domain = await db.getRepository(shell.Domain).save({ name: 'acme', subdomain: 'acme' })
47
+ owner = await db.getRepository(authBase.User).save({ name: 'owner', username: 'owner', email: 'owner@example.com', status: 'activated' })
48
+ member = await db.getRepository(authBase.User).save({ name: 'member', username: 'member', email: 'member@example.com', status: 'activated' })
49
+ })
50
+
51
+ afterAll(async () => {
52
+ shell.removeDataSource('default')
53
+ await db?.destroy()
54
+ })
55
+
56
+ beforeEach(async () => {
57
+ asked = []
58
+ session = await db.getRepository(ChatSession).save({ domain, anchorType: 'domain', anchorId: domain.id, name: 'pricing', visibility: 'group', creator: owner })
59
+ await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: owner.id }, role: 'owner' })
60
+ await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: member.id }, role: 'member' })
61
+ })
62
+
63
+ const person = (name: string, userId: string | null, department = 'Sales', position = 'Manager') => ({
64
+ id: `e-${name}-${userId}`,
65
+ name,
66
+ jobPosition: position,
67
+ department: { id: `d-${department}`, name: department },
68
+ user: userId ? { id: userId } : null
69
+ })
70
+
71
+ /** The people list as the organization's query answers it: by exact name, then by search. */
72
+ function peopleList(rows: any[]) {
73
+ return async (document: string, variables: any) => {
74
+ asked.push({ document, variables })
75
+ const exact = (variables.filters ?? []).find((one: any) => one.name === 'name')
76
+ const items = exact ? rows.filter(row => row.name === exact.value) : rows.filter(row => row.name.includes(variables.search ?? ''))
77
+ return { data: { employees: { items, total: items.length } } }
78
+ }
79
+ }
80
+
81
+ const as = (user: any, conversation: any = session) => ({ state: { domain, user }, ...(conversation ? { conversation: { id: conversation.id } } : {}) })
82
+ const seats = async () => (await db.getRepository(ChatSessionParticipant).find({ where: { session: { id: session.id } } as any })).map((row: any) => [row.userId, row.role]).sort()
83
+
84
+ describe('the invite card', () => {
85
+ it('★ one person by that name - the card invites them, with where they work', async () => {
86
+ const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung'), person('김민수', 'u-kim')]))
87
+ expect(card).toMatchObject({ proposed: true, kind: 'invite', people: [{ userId: 'u-jung', name: '정연희', department: 'Sales', position: 'Manager' }], reason: 'Sales · Manager' })
88
+ })
89
+
90
+ it('★ the card seats nobody - only a person pressing it does (the invite door)', async () => {
91
+ const before = await seats()
92
+ await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung')]))
93
+ expect(await seats()).toEqual(before)
94
+ })
95
+
96
+ it('★ two people of one name - the card lists both and the person picks', async () => {
97
+ const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1', 'Sales'), person('정연희', 'u-2', 'Finance')]))
98
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-1', 'u-2'])
99
+ expect(card.reason).toBeUndefined()
100
+ expect(card.message).toMatch(/do not ask which/i)
101
+ })
102
+
103
+ it('a user id read before narrows the card to that one', async () => {
104
+ const card = await proposeInvite({ name: '정연희', userId: 'u-2' }, as(owner), peopleList([person('정연희', 'u-1'), person('정연희', 'u-2')]))
105
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-2'])
106
+ })
107
+
108
+ it('★ a user id the model made up is never on a card', async () => {
109
+ const card = await proposeInvite({ name: '정연희', userId: 'made-up' }, as(owner), peopleList([person('정연희', 'u-1')]))
110
+ expect(card).toMatchObject({ rejected: true, error: 'not-found' })
111
+ })
112
+
113
+ it('no exact name - the name is searched', async () => {
114
+ const card = await proposeInvite({ name: '연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
115
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-1'])
116
+ expect(asked[1].variables.search).toBe('연희')
117
+ })
118
+
119
+ it('people who left are never asked for, and no contact or bank field is read', async () => {
120
+ await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
121
+ expect(asked[0].variables.filters).toContainEqual({ name: 'active', operator: 'eq', value: true })
122
+ expect(asked[0].document).not.toMatch(/phone|email|address|bank|emergency|contact|photo/i)
123
+ })
124
+
125
+ it('someone without an account cannot be invited; someone already here is said', async () => {
126
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', null)]))).toMatchObject({ rejected: true, error: 'not-found' })
127
+ expect(await proposeInvite({ name: 'member' }, as(owner), peopleList([person('member', member.id)]))).toMatchObject({ rejected: true, error: 'already-here' })
128
+ })
129
+
130
+ it('a name that matches too many is narrowed first', async () => {
131
+ const many = Array.from({ length: MAX_INVITE_CANDIDATES + 1 }, (_, i) => person('정연희', `u-${i}`))
132
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList(many))).toMatchObject({ rejected: true, error: 'too-many' })
133
+ })
134
+ })
135
+
136
+ describe('the card is offered only where the person could press it', () => {
137
+ it('★ a member of a group cannot bring anyone in - no card, and nobody is looked up', async () => {
138
+ const card = await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))
139
+ expect(card).toMatchObject({ rejected: true, error: 'may-not-invite' })
140
+ expect(card.message).toMatch(/you cannot change it, so never offer/)
141
+ expect(asked).toEqual([])
142
+ })
143
+
144
+ it('★ a private conversation takes nobody', async () => {
145
+ await db.getRepository(ChatSession).update(session.id, { visibility: 'private' })
146
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))).toMatchObject({ rejected: true, error: 'may-not-invite' })
147
+ })
148
+
149
+ it('in an open conversation anyone may point a colleague at it', async () => {
150
+ await db.getRepository(ChatSession).update(session.id, { visibility: 'open' })
151
+ expect(await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))).toMatchObject({ proposed: true })
152
+ })
153
+
154
+ it('a turn without a stored conversation, or one of another domain, has no room to invite into', async () => {
155
+ expect(await proposeInvite({ name: '정연희' }, as(owner, null), peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
156
+ const other = await db.getRepository(shell.Domain).save({ name: 'other', subdomain: 'other' })
157
+ expect(await proposeInvite({ name: '정연희' }, { state: { domain: other, user: owner }, conversation: { id: session.id } }, peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
158
+ })
159
+ })
@@ -0,0 +1,16 @@
1
+ /*
2
+ * The names of the people tools - every conversation carries them (mode-categories.ts), so they live apart from the
3
+ * tools themselves and the categories can be named without loading them.
4
+ */
5
+
6
+ /** The organization's people as a read target - its `employees` list, read as the person (people-read.ts). */
7
+ export const PEOPLE_READ_KEY = 'organization.employee'
8
+
9
+ /** The category of that read - `data-read:<key>`, as every read target's. */
10
+ export const PEOPLE_READ_CATEGORY = `data-read:${PEOPLE_READ_KEY}`
11
+
12
+ /** The invite card - bring someone into this conversation (invite-tools.ts). */
13
+ export const INVITE_CATEGORY = 'chat-invite'
14
+
15
+ /** Both, in the order a turn puts them in front: find the person, then the card. */
16
+ export const PEOPLE_CATEGORIES = [PEOPLE_READ_CATEGORY, INVITE_CATEGORY]
@@ -0,0 +1,82 @@
1
+ /*
2
+ * The people list every conversation reads (people-read.ts) - the organization's own query, behind its own door, and
3
+ * nothing private in what it reads.
4
+ */
5
+ import { peopleReadTarget, PEOPLE_SELECTION } from './people-read'
6
+ import { PEOPLE_CATEGORIES, PEOPLE_READ_CATEGORY, INVITE_CATEGORY } from './people-keys'
7
+ import { modeFrontCategories, modeToolCategories, unattendedToolCategories } from '../assistant-mode/mode-categories'
8
+ import { rootFieldOf } from '../data-entry/entry-target'
9
+
10
+ describe('the people list', () => {
11
+ const target = peopleReadTarget()
12
+
13
+ it("★ reads the organization's own list - its door decides who may read people", () => {
14
+ expect(rootFieldOf(target.query, 'query')).toBe('Query.employees')
15
+ expect(target.key).toBe('organization.employee')
16
+ })
17
+
18
+ it('★ the invite card reads people through the same door - offered only where the list is', () => {
19
+ /* The tool module holds entities - read as built, as the database suites do. */
20
+ const { inviteCategory } = require(require('path').join(__dirname, '..', '..', '..', 'dist-server', 'service', 'people', 'invite-tools.js'))
21
+ expect(inviteCategory.specs.map((spec: any) => spec.doors)).toEqual([[rootFieldOf(target.query, 'query')]])
22
+ })
23
+
24
+ it('★ reads no contact, address, bank or emergency field', () => {
25
+ expect(target.query).not.toMatch(/phone|email|address|bank|emergency|contact|photo|profile|note/i)
26
+ expect(PEOPLE_SELECTION.split(/[\s{}]+/).filter(Boolean).sort()).toEqual(['alias', 'department', 'id', 'id', 'id', 'jobPosition', 'jobResponsibility', 'name', 'name', 'user'].sort())
27
+ })
28
+
29
+ it('people who left are out of reach - a fixed filter the model cannot lift', () => {
30
+ expect(target.fixedFilters).toEqual([{ name: 'active', operator: 'eq', value: true }])
31
+ })
32
+
33
+ it('★ a department, a position, a responsibility each has its own condition - never the name search', () => {
34
+ const by = (name: string) => target.criteria.find(one => one.name === name)!
35
+ expect(by('position').filter('프로')).toEqual({ name: 'jobPosition', operator: 'i_like', value: '프로' })
36
+ expect(by('responsibility').filter('회계')).toEqual({ name: 'jobResponsibility', operator: 'i_like', value: '회계' })
37
+ expect(by('department').filter('영업')).toEqual({ name: 'departmentName', operator: 'i_like', value: '영업' })
38
+ })
39
+
40
+ it("★ every part-of condition is on a field the organization's list lets LIKE reach - one outside is dropped without a word", () => {
41
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
42
+ const searchables = JSON.parse(source.match(/searchables: (\[[^\]]*\])/)![1].replace(/'/g, '"')) as string[]
43
+ const liked = target.criteria.map(one => [one.filter('x')].flat()).flat().filter((f: any) => /like/.test(f.operator)).map((f: any) => f.name)
44
+ expect(liked.length).toBeGreaterThan(0)
45
+ expect(liked.filter(name => !searchables.includes(name))).toEqual([])
46
+ })
47
+
48
+ it("the organization's list takes a department by its name", () => {
49
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
50
+ expect(source).toMatch(/filtersMap: \{ departmentName: \{ columnName: 'name', relationColumn: 'department' \} \}/)
51
+ })
52
+
53
+ it('carries the user id an invite takes, and finds by name', () => {
54
+ expect(target.columns.find(one => one.name === 'userId')?.path).toBe('user.id')
55
+ expect(target.search).toBeTruthy()
56
+ })
57
+ })
58
+
59
+ describe('every conversation carries the people tools', () => {
60
+ const mode = { toolCategories: ['office-reads'], readTargets: ['office.contract'] }
61
+
62
+ it('★ offered in every mode, and in front of every turn', () => {
63
+ for (const category of PEOPLE_CATEGORIES) {
64
+ expect(modeToolCategories(mode)).toContain(category)
65
+ expect(modeToolCategories({})).toContain(category)
66
+ expect(modeFrontCategories(mode)).toContain(category)
67
+ expect(modeFrontCategories({}, { page: 'anything' })).toContain(category)
68
+ }
69
+ })
70
+
71
+ it('not in an unattended run - nobody is there to press an invite', () => {
72
+ expect(unattendedToolCategories(mode)).not.toContain(INVITE_CATEGORY)
73
+ expect(unattendedToolCategories(mode)).not.toContain(PEOPLE_READ_CATEGORY)
74
+ })
75
+ })
76
+
77
+ describe('the invite card knows which conversation it is in', () => {
78
+ it("★ the conversation door hands the stored session to the tools - the server's value, never the client's", () => {
79
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', 'assistant-chat-resolver.ts'), 'utf8') as string
80
+ expect(source).toMatch(/\.\.\.\(session\?\.id \? \{ conversation: \{ id: session\.id \} \} : \{\}\)/)
81
+ })
82
+ })
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Who works here - the organization's `employees` list opened to every conversation as a read target
3
+ * (operato-application/design/ai/read-targets.md), the way an application opens its own lists.
4
+ *
5
+ * Asked to bring 정연희 into a group conversation, the model answered that it had no tool to look up people and
6
+ * sent the person to search a screen (2026-10-24). The list existed; no conversation could read it.
7
+ *
8
+ * Nothing new is built: the read runs the organization module's own query as the person, so its door
9
+ * (`employee` query, or the domain owner) decides who may read people, and an application without the organization
10
+ * module has no such door - the tool is not offered there. The selection is the projection: name, department,
11
+ * position and the user an invite takes. No phone, e-mail, address, bank account or emergency contact is read.
12
+ */
13
+ import { listReadTarget } from '../data-read/list-read.js'
14
+ import type { ReadTarget } from '../data-read/read-target.js'
15
+ import { PEOPLE_READ_KEY } from './people-keys.js'
16
+
17
+ /** The fields read of each person - the answer may carry nothing else. */
18
+ export const PEOPLE_SELECTION = 'id name alias jobPosition jobResponsibility department { id name } user { id }'
19
+
20
+ export function peopleReadTarget(): ReadTarget {
21
+ const target = listReadTarget({
22
+ key: PEOPLE_READ_KEY,
23
+ title: 'ai-assistant.people.title',
24
+ name: 'people of the organization',
25
+ listQuery: 'employees',
26
+ /* People who left are not offered - an invite, a mention or an answer about who does what is about who is here. */
27
+ fixedFilters: [{ name: 'active', operator: 'eq', value: true }],
28
+ selection: PEOPLE_SELECTION,
29
+ columns: [
30
+ { name: 'name', type: 'string', labelKey: 'ai-assistant.people.name' },
31
+ { name: 'department', type: 'string', path: 'department.name', labelKey: 'ai-assistant.people.department' },
32
+ { name: 'position', type: 'string', path: 'jobPosition', labelKey: 'ai-assistant.people.position' },
33
+ { name: 'responsibility', type: 'string', path: 'jobResponsibility', labelKey: 'ai-assistant.people.responsibility' },
34
+ { name: 'userId', type: 'string', path: 'user.id', labelKey: 'ai-assistant.people.user' }
35
+ ],
36
+ search: 'name, employee number and alias'
37
+ })
38
+ /*
39
+ * Who holds a position, who is in a team - asked 「프로 직책인 사람은 누구야?」 the model put the position into the search
40
+ * term, which matches names, numbers and aliases only, and answered that nobody held it (2026-10-24).
41
+ */
42
+ const contains = (name: string) => (value: any) => ({ name, operator: 'i_like', value })
43
+ return {
44
+ ...target,
45
+ criteria: [
46
+ ...target.criteria,
47
+ { name: 'department', type: 'string', labelKey: 'ai-assistant.people.department', description: "part of the department's name", filter: contains('departmentName') },
48
+ { name: 'position', type: 'string', labelKey: 'ai-assistant.people.position', description: 'part of the position (직책)', filter: contains('jobPosition') },
49
+ { name: 'responsibility', type: 'string', labelKey: 'ai-assistant.people.responsibility', description: 'part of what they are responsible for (담당)', filter: contains('jobResponsibility') }
50
+ ],
51
+ description:
52
+ 'the people who work here, as the organization lists them - name, department, position, responsibility, and the user id ' +
53
+ 'an invite into a conversation takes (empty when the person has no account). Read only who the asker may see. ' +
54
+ 'The text condition finds by name, employee number or alias only - a department, position or responsibility has its own condition.'
55
+ }
56
+ }
@@ -13,7 +13,7 @@
13
13
  import { clearAssistantModes, registerAssistantMode } from '../assistant-mode/registry'
14
14
  import { clearToolRegistry, registerToolCategory } from '@things-factory/ai-client-base'
15
15
 
16
- import { runRegisteredMode } from './mode-conformance'
16
+ import { modeCarriesSharedRules, runRegisteredMode } from './mode-conformance'
17
17
 
18
18
  const client = {
19
19
  chatWithTools: async () => ({ text: '한 턴', toolCalls: [], stopReason: 'end_turn' })
@@ -46,3 +46,16 @@ it('an app that wants to see the other case names it — the door the caller can
46
46
 
47
47
  expect(result.offeredTools).toEqual(['getOpenScene'])
48
48
  })
49
+
50
+ describe('an app prompt that copies a shared rule', () => {
51
+ afterEach(() => clearAssistantModes())
52
+
53
+ it('★ reading the picked records in its own words is a copy of the selection rule', () => {
54
+ registerAssistantMode({ kind: 'office.copy', systemPrompt: 'explicit-page-selection is what the person picked.' } as any)
55
+ registerAssistantMode({ kind: 'office.keys', systemPrompt: 'read the [keys] line.' } as any)
56
+ registerAssistantMode({ kind: 'office.clean', systemPrompt: 'you answer about the books.' } as any)
57
+ expect(modeCarriesSharedRules('office.copy')).toContain('selection-rule-copy')
58
+ expect(modeCarriesSharedRules('office.keys')).toContain('selection-rule-copy')
59
+ expect(modeCarriesSharedRules('office.clean')).toEqual([])
60
+ })
61
+ })
@@ -147,6 +147,8 @@ export function modeCarriesSharedRules(kind: string): string[] {
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
148
  if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
149
149
  if (/\[writer → addressee\]/.test(prompt)) found.push('speaker-rule-copy')
150
+ /* The picked records' note is the framework's form (SELECTION_RULE) - an app reading its own form of it is a copy. */
151
+ if (/explicit-page-selection|\[keys\]/i.test(prompt)) found.push('selection-rule-copy')
150
152
  return found
151
153
  }
152
154