@things-factory/ai-assistant 10.1.157 → 10.1.159

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 (75) hide show
  1. package/client/components/assistant-chat.ts +24 -213
  2. package/client/components/assistant-dock.ts +11 -32
  3. package/client/components/assistant-mode.test.ts +1 -43
  4. package/client/components/assistant-mode.ts +0 -47
  5. package/client/utils/assistant-surface.ts +1 -1
  6. package/client/utils/composer-frame.ts +6 -4
  7. package/dist-client/components/assistant-chat.d.ts +5 -69
  8. package/dist-client/components/assistant-chat.js +24 -205
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-dock.d.ts +0 -3
  11. package/dist-client/components/assistant-dock.js +10 -34
  12. package/dist-client/components/assistant-dock.js.map +1 -1
  13. package/dist-client/components/assistant-mode.d.ts +0 -33
  14. package/dist-client/components/assistant-mode.js +0 -37
  15. package/dist-client/components/assistant-mode.js.map +1 -1
  16. package/dist-client/components/assistant-mode.test.js +1 -35
  17. package/dist-client/components/assistant-mode.test.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-client/utils/assistant-surface.d.ts +1 -1
  20. package/dist-client/utils/assistant-surface.js.map +1 -1
  21. package/dist-client/utils/composer-frame.d.ts +5 -3
  22. package/dist-client/utils/composer-frame.js +4 -4
  23. package/dist-client/utils/composer-frame.js.map +1 -1
  24. package/dist-server/index.d.ts +1 -0
  25. package/dist-server/index.js +2 -0
  26. package/dist-server/index.js.map +1 -1
  27. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  28. package/dist-server/service/assistant-chat-resolver.js +30 -6
  29. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  30. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -2
  31. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +13 -24
  32. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  33. package/dist-server/service/assistant-mode/fallback-subjects.d.ts +1 -0
  34. package/dist-server/service/assistant-mode/fallback-subjects.js +37 -0
  35. package/dist-server/service/assistant-mode/fallback-subjects.js.map +1 -0
  36. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  37. package/dist-server/service/assistant-mode/index.js +7 -2
  38. package/dist-server/service/assistant-mode/index.js.map +1 -1
  39. package/dist-server/service/assistant-mode/registry.d.ts +2 -6
  40. package/dist-server/service/assistant-mode/registry.js +16 -56
  41. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  42. package/dist-server/service/assistant-mode/resolve.js +3 -1
  43. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  44. package/dist-server/service/assistant-mode/screen-rules.d.ts +25 -0
  45. package/dist-server/service/assistant-mode/screen-rules.js +47 -0
  46. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -0
  47. package/dist-server/service/assistant-mode/types.d.ts +8 -25
  48. package/dist-server/service/assistant-mode/types.js.map +1 -1
  49. package/dist-server/service/testing/mode-conformance.d.ts +11 -0
  50. package/dist-server/service/testing/mode-conformance.js +28 -0
  51. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  52. package/dist-server/tool-registry-boot-report.d.ts +1 -1
  53. package/dist-server/tool-registry-boot-report.js +16 -7
  54. package/dist-server/tool-registry-boot-report.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +6 -6
  57. package/server/index.ts +2 -0
  58. package/server/service/assistant-chat-resolver.ts +33 -7
  59. package/server/service/assistant-mode/assistant-mode-resolver.ts +12 -25
  60. package/server/service/assistant-mode/fallback-subjects.ts +36 -0
  61. package/server/service/assistant-mode/index.ts +2 -1
  62. package/server/service/assistant-mode/one-conversation-per-subject.test.ts +37 -0
  63. package/server/service/assistant-mode/open-subject-door.test.ts +29 -0
  64. package/server/service/assistant-mode/registry.ts +14 -55
  65. package/server/service/assistant-mode/resolve.test.ts +10 -1
  66. package/server/service/assistant-mode/resolve.ts +3 -1
  67. package/server/service/assistant-mode/screen-rules.test.ts +39 -0
  68. package/server/service/assistant-mode/screen-rules.ts +47 -0
  69. package/server/service/assistant-mode/types.ts +8 -31
  70. package/server/service/testing/mode-conformance.ts +27 -1
  71. package/server/tool-registry-boot-report.test.ts +14 -1
  72. package/server/tool-registry-boot-report.ts +16 -7
  73. package/test/assistant-dock.test.ts +27 -4
  74. package/test/composer-frame.test.ts +12 -2
  75. package/server/service/assistant-mode/subject-defaults.test.ts +0 -86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.157",
3
+ "version": "10.1.159",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,12 +34,12 @@
34
34
  "@operato/popup": "^10.16.0",
35
35
  "@operato/shell": "^10.0.0",
36
36
  "@operato/styles": "^10.0.0",
37
- "@things-factory/ai-client-base": "^10.1.157",
37
+ "@things-factory/ai-client-base": "^10.1.159",
38
38
  "@things-factory/apptool-base": "^10.1.157",
39
- "@things-factory/attachment-base": "^10.1.157",
40
- "@things-factory/auth-base": "^10.1.157",
39
+ "@things-factory/attachment-base": "^10.1.159",
40
+ "@things-factory/auth-base": "^10.1.159",
41
41
  "@things-factory/env": "^10.1.149",
42
- "@things-factory/setting-base": "^10.1.157",
42
+ "@things-factory/setting-base": "^10.1.159",
43
43
  "@things-factory/shell": "^10.1.157",
44
44
  "dompurify": "^3.0.0",
45
45
  "exceljs": "4.2.0",
@@ -54,5 +54,5 @@
54
54
  "copyfiles": "^2.4.1",
55
55
  "rimraf": "^5.0.0"
56
56
  },
57
- "gitHead": "bde810f804966549a7041457ad7ede40fcb78488"
57
+ "gitHead": "fc15cdcb18e562ca1e723a1e1bf042f9582cbbfb"
58
58
  }
package/server/index.ts CHANGED
@@ -27,3 +27,5 @@
27
27
  */
28
28
  export * from './service/index.js'
29
29
  import './tool-registry-boot-report.js'
30
+ /* The site, an application's home and a board are named without an application's help (design/ai/screen-pattern.md §2-1). */
31
+ import './service/assistant-mode/fallback-subjects.js'
@@ -27,7 +27,7 @@ import { GraphQLJSON } from 'graphql-scalars'
27
27
  import '@things-factory/auth-base'
28
28
 
29
29
  import { getRepository, rethrowSpoken } from '@things-factory/shell'
30
- import { aiClientFor, findToolSpec, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
30
+ import { aiClientFor, findToolSpec, openSubjectLine, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
31
31
 
32
32
  import { ChatSession } from './chat-session/chat-session.js'
33
33
  import { assertStationSession } from './chat-session/station-session-guard.js'
@@ -52,6 +52,7 @@ import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-se
52
52
  import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
53
53
  import { getAssistantMode } from './assistant-mode/registry.js'
54
54
  import { resolveModeSettings } from './assistant-mode/resolve.js'
55
+ import { splitNextRequest } from './assistant-mode/screen-rules.js'
55
56
  import { requestTranslator } from './request-translator.js'
56
57
 
57
58
  /**
@@ -138,6 +139,13 @@ class AssistantChatOutput {
138
139
  @Field({ description: 'The reply, including the notice when the loop stopped early.' })
139
140
  reply: string
140
141
 
142
+ @Field(() => String, {
143
+ nullable: true,
144
+ description:
145
+ 'The request the person is likely to make next, as the reply named it. The input shows it and Tab writes it in. Not stored with the reply.'
146
+ })
147
+ suggestion?: string | null
148
+
141
149
  @Field({ description: 'AI client identifier (provider:model).' })
142
150
  clientId: string
143
151
 
@@ -290,6 +298,20 @@ export class AssistantChatResolver {
290
298
 
291
299
  assertStationSession(session, input.hostContext, t)
292
300
 
301
+ /*
302
+ * 열린 대상 — 서버가 저장한 앵커에서 턴마다 한 줄(design/ai/screen-pattern.md §2-1). 화면이 보낸 hostContext 는
303
+ * 쓰지 않는다. 대상이 바뀌면 다음 턴의 줄도 바뀌므로 옛 대상이 대화에 남지 않는다.
304
+ */
305
+ const subjectLine = session
306
+ ? await openSubjectLine({
307
+ anchorType: (session as any).anchorType,
308
+ anchorId: (session as any).anchorId,
309
+ domain,
310
+ state: context.state
311
+ })
312
+ : undefined
313
+ const systemPrompt = subjectLine ? `${settings.systemPrompt}\n\n${subjectLine}` : settings.systemPrompt
314
+
293
315
  const lastUserMessage = input.messages[input.messages.length - 1]
294
316
  let userMessageId: string | undefined
295
317
 
@@ -480,7 +502,7 @@ export class AssistantChatResolver {
480
502
  const result = await registryAssistantChat({
481
503
  client,
482
504
  messages: conversation as any,
483
- systemPrompt: settings.systemPrompt,
505
+ systemPrompt,
484
506
  toolCategories: settings.toolCategories,
485
507
  /* 도구가 「어느 대화에서 불렸나」를 안다 — 서버의 값이다(ADR-0061 결정 3). */
486
508
  mode: { kind: input.mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
@@ -495,7 +517,7 @@ export class AssistantChatResolver {
495
517
  * 자체를 안 보게 된다.
496
518
  */
497
519
  groundingSources: groundingSourcesFor({
498
- systemPrompt: settings.systemPrompt,
520
+ systemPrompt,
499
521
  messages: conversation as any,
500
522
  hostContext: input.hostContext
501
523
  }),
@@ -509,6 +531,9 @@ export class AssistantChatResolver {
509
531
  * 이 답이 지나온 문 — ADR-0073 판정 1. 행에도, 목록 미리보기에도 같은 값이 간다. 한쪽만 적으면
510
532
  * 가려진 답이 목록에는 그대로 뜬다.
511
533
  */
534
+ /* The reply ends with the next request when one is natural; the input offers it and the stored answer does not keep it (§4-1). */
535
+ const { reply, suggestion } = splitNextRequest(result.reply)
536
+
512
537
  const answerDoors = doorsForAnswer(
513
538
  (result.loop.toolUsages ?? []).map((usage: any) => usage?.name).filter(Boolean),
514
539
  name => (findToolSpec(name, settings.toolCategories) as any)?.doors
@@ -519,7 +544,7 @@ export class AssistantChatResolver {
519
544
  const stored = await messageRepo.save({
520
545
  session: { id: session.id } as any,
521
546
  role: 'assistant',
522
- content: result.reply,
547
+ content: reply,
523
548
  /*
524
549
  * 이 답이 지나온 문을 함께 적는다 — ADR-0073 판정 1. 방은 도메인 안에서 공개이므로, 이 문장이
525
550
  * 실어 나르는 값을 나중에 읽는 사람이 열 수 있는지 판정할 근거가 행에 있어야 한다. 적지 않으면
@@ -543,7 +568,7 @@ export class AssistantChatResolver {
543
568
  kind: 'message',
544
569
  anchorType: (session as any).anchorType,
545
570
  anchorId: (session as any).anchorId,
546
- preview: result.reply,
571
+ preview: reply,
547
572
  actorId: user?.id
548
573
  })
549
574
 
@@ -551,7 +576,7 @@ export class AssistantChatResolver {
551
576
  aiClientId: client.id,
552
577
  agentId: client.agent.id,
553
578
  updater: user,
554
- ...activityPatch(result.reply, new Date(), answerDoors)
579
+ ...activityPatch(reply, new Date(), answerDoors)
555
580
  } as any)
556
581
 
557
582
  /*
@@ -573,7 +598,8 @@ export class AssistantChatResolver {
573
598
  }
574
599
 
575
600
  return {
576
- reply: result.reply,
601
+ reply,
602
+ suggestion: suggestion ?? null,
577
603
  clientId: client.id,
578
604
  agentId: client.agent.id,
579
605
  sessionId: session?.id,
@@ -12,7 +12,7 @@ import { GraphQLJSON } from 'graphql-scalars'
12
12
  import '@things-factory/auth-base'
13
13
 
14
14
  import { assistantModeView } from './resolve.js'
15
- import { assistantModesForSubject } from './registry.js'
15
+ import { assistantModeForSubject } from './registry.js'
16
16
  import { requestTranslator } from '../request-translator.js'
17
17
 
18
18
  @ObjectType({ description: 'One `/` sentence, in the words of this request.' })
@@ -63,45 +63,32 @@ class AssistantModeView {
63
63
  attachments?: any
64
64
  }
65
65
 
66
- @ObjectType({ description: 'A conversation a screen may open for what it is showing.' })
66
+ @ObjectType({ description: 'The conversation a screen opens for what it is showing.' })
67
67
  class AssistantModeChoice {
68
68
  @Field({ description: 'The mode kind to open.' })
69
69
  kind: string
70
70
 
71
- @Field({ description: 'What to call it where a person chooses — already in the words of this request.' })
71
+ @Field({ description: 'What the conversation is called in the conversation list, in the words of this request.' })
72
72
  label: string
73
-
74
- @Field({ description: 'Whether this is the one to open when the person has not chosen.' })
75
- isDefault: boolean
76
73
  }
77
74
 
78
75
  @Resolver()
79
76
  export class AssistantModeResolver {
80
77
  /*
81
- * 이 화면이 보여 주는 것으로 열 수 있는 대화들 — ADR-0072 A17 ①.
82
- *
83
- * 화면은 자기가 무엇을 보여 주는지(`subject`)만 안다. 그 위에 어느 대화가 서는지는 앱 서버가
84
- * 선언한다. 화면이 종류 목록을 손으로 들고 있으면, 대화를 하나 더 세운 날 그 화면만 모른 채로 남는다.
78
+ * 이 화면의 대화 — 화면에 대화 하나(design/ai/screen-pattern.md §3).
85
79
  *
86
- * 하나만 오면 고르개를 내지 않는다 — 고를 것이 없는 고르개는 자리만 차지한다.
80
+ * 화면은 자기가 무엇을 보여 주는지(`subject`)만 안다. 그 위에 어느 대화가 서는지는 앱 서버가 선언한다.
87
81
  */
88
- @Query(() => [AssistantModeChoice], {
89
- description:
90
- 'Conversations that stand for this kind of subject, the default first. A screen showing one subject opens the head of this list and offers the rest; a single entry means there is nothing to choose.'
82
+ @Query(() => AssistantModeChoice, {
83
+ nullable: true,
84
+ description: 'The one conversation for this kind of subject. Null when no application declared one.'
91
85
  })
92
86
  @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
93
- async assistantModes(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice[]> {
87
+ async assistantModeFor(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice | null> {
88
+ const mode = assistantModeForSubject(subject)
89
+ if (!mode) return null
94
90
  const t = requestTranslator(context)
95
-
96
- return assistantModesForSubject(subject).map(mode => ({
97
- kind: mode.kind,
98
- /*
99
- * 이름이 없는 대화는 종류를 그대로 쓴다. 부팅 보고가 이미 그것을 경고로 말하므로 여기서 숨기지
100
- * 않는다 — 화면에 `twin.space` 가 뜨면 그것이 빠진 선언을 가리킨다.
101
- */
102
- label: mode.labelKey ? t(mode.labelKey) : mode.kind,
103
- isDefault: (mode.defaultFor ?? []).includes(subject)
104
- }))
91
+ return { kind: mode.kind, label: mode.labelKey ? t(mode.labelKey) : mode.kind }
105
92
  }
106
93
  @Query(() => AssistantModeView, {
107
94
  description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The open subjects the shared parts can name without an application's help (design/ai/screen-pattern.md §2-1):
3
+ * the site (`domain`), an application's home (`application`) and a board (`board`). An application that names
4
+ * one of these differently registers its own describer, which is read first.
5
+ *
6
+ * A board is read by entity name: the board entity belongs to board-service, and this package does not depend on
7
+ * it. A deployment without boards has no board anchors, and a read that fails says the subject could not be read.
8
+ */
9
+ import { Domain, getRepository } from '@things-factory/shell'
10
+ import { registerFallbackSubject } from '@things-factory/ai-client-base'
11
+
12
+ registerFallbackSubject({
13
+ anchorType: 'domain',
14
+ async describe({ anchorId, domain }) {
15
+ /* The site of this conversation only - another domain's name is not this person's to read. */
16
+ if (anchorId !== domain.id) return undefined
17
+ const found = await getRepository(Domain).findOne({ where: { id: anchorId } })
18
+ return found ? { noun: 'site', name: found.description || found.name } : undefined
19
+ }
20
+ })
21
+
22
+ registerFallbackSubject({
23
+ anchorType: 'application',
24
+ async describe({ anchorId }) {
25
+ /* The application's home is a place, not a record: nothing narrower is open. */
26
+ return { noun: 'home of the application', name: anchorId }
27
+ }
28
+ })
29
+
30
+ registerFallbackSubject({
31
+ anchorType: 'board',
32
+ async describe({ anchorId, domain }) {
33
+ const found: any = await getRepository('Board').findOne({ where: { id: anchorId, domain: { id: domain.id } } })
34
+ return found?.name ? { noun: 'board', name: found.name } : undefined
35
+ }
36
+ })
@@ -7,10 +7,11 @@ export {
7
7
  registerAssistantMode,
8
8
  getAssistantMode,
9
9
  listAssistantModes,
10
- assistantModesForSubject,
10
+ assistantModeForSubject,
11
11
  getAssistantModeWarnings,
12
12
  clearAssistantModes
13
13
  } from './registry.js'
14
14
  export { resolveModeSettings, assistantModeView } from './resolve.js'
15
+ export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
15
16
  export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning } from './types.js'
16
17
  export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
@@ -0,0 +1,37 @@
1
+ /*
2
+ * A screen has one conversation (operato-application design/ai/screen-pattern.md §3).
3
+ *
4
+ * Two conversations on one subject put a picker in front of the person, a choice between our internal units. The
5
+ * boot report names the subject instead, and the screen opens the one conversation for what it shows.
6
+ */
7
+ import { assistantModeForSubject, clearAssistantModes, getAssistantModeWarnings, registerAssistantMode } from './registry'
8
+
9
+ const persona = 'you are here'
10
+
11
+ describe('assistant mode — one conversation per subject', () => {
12
+ beforeEach(() => clearAssistantModes())
13
+ afterAll(() => clearAssistantModes())
14
+
15
+ it('★ two conversations on one subject is a boot warning naming both', () => {
16
+ registerAssistantMode({ kind: 'twin.board', systemPrompt: persona, subjects: ['board'] })
17
+ registerAssistantMode({ kind: 'twin.space', systemPrompt: persona, subjects: ['space', 'board'] })
18
+
19
+ const twice = getAssistantModeWarnings().filter(w => w.code === 'assistant-mode-subject-claimed-twice')
20
+ expect(twice.map(w => w.params.subject)).toEqual(['board'])
21
+ expect(twice[0].params.kinds).toEqual(['twin.board', 'twin.space'])
22
+ })
23
+
24
+ it('one conversation on each subject says nothing', () => {
25
+ registerAssistantMode({ kind: 'twin.board', systemPrompt: persona, subjects: ['board'] })
26
+ registerAssistantMode({ kind: 'twin.space', systemPrompt: persona, subjects: ['space'] })
27
+
28
+ expect(getAssistantModeWarnings()).toEqual([])
29
+ })
30
+
31
+ it('★ a screen opens the conversation for its subject, and nothing for a subject nobody declared', () => {
32
+ registerAssistantMode({ kind: 'plant.station', systemPrompt: persona, subjects: ['station'] })
33
+
34
+ expect(assistantModeForSubject('station')?.kind).toBe('plant.station')
35
+ expect(assistantModeForSubject('nothing-shows-this')).toBeUndefined()
36
+ })
37
+ })
@@ -0,0 +1,29 @@
1
+ /*
2
+ * The door says what is open from the stored anchor (operato-application design/ai/screen-pattern.md §2-1 · §4-1).
3
+ */
4
+ import { readFileSync } from 'fs'
5
+ import { join } from 'path'
6
+
7
+ const door = readFileSync(join(__dirname, '../assistant-chat-resolver.ts'), 'utf8')
8
+
9
+ describe('the door', () => {
10
+ it('★ builds the line from the stored session anchor every turn, never from what the screen sent', () => {
11
+ const block = door.slice(door.indexOf('const subjectLine = session'), door.indexOf('const systemPrompt = subjectLine'))
12
+ expect(block).toMatch(/anchorType: \(session as any\)\.anchorType/)
13
+ expect(block).toMatch(/anchorId: \(session as any\)\.anchorId/)
14
+ expect(block).not.toMatch(/hostContext/)
15
+ })
16
+
17
+ it('★ the model and the grounding check both read the prompt with the line', () => {
18
+ expect(door.match(/^\s+systemPrompt,$/gm)).toHaveLength(2)
19
+ expect(door).not.toMatch(/systemPrompt: settings\.systemPrompt/)
20
+ })
21
+
22
+ it('★ the stored answer, the list preview and the reply leave out the Next: line', () => {
23
+ expect(door).toMatch(/const \{ reply, suggestion \} = splitNextRequest\(result\.reply\)/)
24
+ expect(door).toMatch(/content: reply,/)
25
+ expect(door).toMatch(/preview: reply,/)
26
+ expect(door).toMatch(/activityPatch\(reply, /)
27
+ expect(door).not.toMatch(/content: result\.reply/)
28
+ })
29
+ })
@@ -62,47 +62,20 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
62
62
  const out: AssistantModeWarning[] = []
63
63
 
64
64
  /*
65
- * 한 화면이 어느 대화를 기본으로 여는가 — 등록 순서로 정하지 않는다(ADR-0072 A17 ②). 없으면 화면이
66
- * 무엇을 열지 모르고, 둘이면 마지막에 부팅한 모듈이 조용히 이긴다. 둘 다 여기서 말한다.
65
+ * 한 화면에 대화 하나 — design/ai/screen-pattern.md §3. 같은 subject 를 두 대화가 적으면 화면은 어느 쪽을
66
+ * 열지 모르고, 고르개로 사람에게 넘기면 원칙 3 을 어긴다. 성격이 다른 물음은 그 대화가 도구를 더 연다.
67
67
  */
68
- const defaults = new Map<string, string[]>()
69
- const claimed = new Set<string>()
68
+ const owners = new Map<string, string[]>()
70
69
  for (const mode of modes.values()) {
71
- for (const subject of mode.subjects ?? []) claimed.add(subject)
72
- for (const subject of mode.defaultFor ?? []) {
73
- defaults.set(subject, [...(defaults.get(subject) ?? []), mode.kind])
74
- }
70
+ for (const subject of mode.subjects ?? []) owners.set(subject, [...(owners.get(subject) ?? []), mode.kind])
75
71
  }
76
- for (const subject of claimed) {
77
- const owners = defaults.get(subject) ?? []
78
- if (owners.length === 0) {
79
- out.push({
80
- code: 'assistant-mode-subject-without-default',
81
- params: { kind: '', subject },
82
- message: `no conversation is the default for subject '${subject}' — a screen showing it has nothing to open`
83
- })
84
- } else if (owners.length > 1) {
85
- out.push({
86
- code: 'assistant-mode-subject-default-twice',
87
- params: { kind: owners[0], subject, kinds: owners },
88
- message: `subject '${subject}' has more than one default (${owners.join(', ')}) — boot order would decide it`
89
- })
90
- }
91
- }
92
-
93
- for (const mode of modes.values()) {
94
- /*
95
- * 고를 수 있는 대화에는 부를 이름이 있어야 한다 — 고르개에 종류 이름(`twin.space`)이 뜨면 그것은
96
- * 우리 내부 단위를 사람에게 떠넘기는 것이다.
97
- */
98
- const shared = (mode.subjects ?? []).some(subject => countModesFor(subject) > 1)
99
- if (shared && !mode.labelKey) {
100
- out.push({
101
- code: 'assistant-mode-offered-without-label',
102
- params: { kind: mode.kind },
103
- message: `mode '${mode.kind}' can be offered beside another on the same subject but has no labelKey`
104
- })
105
- }
72
+ for (const [subject, kinds] of owners) {
73
+ if (kinds.length < 2) continue
74
+ out.push({
75
+ code: 'assistant-mode-subject-claimed-twice',
76
+ params: { kind: kinds[0], subject, kinds },
77
+ message: `subject '${subject}' has more than one conversation (${kinds.join(', ')}) — a screen has one conversation; open more tools in one of them`
78
+ })
106
79
  }
107
80
 
108
81
  for (const mode of modes.values()) {
@@ -135,23 +108,9 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
135
108
  return out
136
109
  }
137
110
 
138
- /** 이 subject 종류에 서는 대화가 몇인가 — 고르개가 필요한지, 이름이 필요한지를 가른다. */
139
- function countModesFor(subject: string): number {
140
- let count = 0
141
- for (const mode of modes.values()) {
142
- if ((mode.subjects ?? []).includes(subject)) count++
143
- }
144
- return count
145
- }
146
-
147
- /**
148
- * 이 subject 종류에 서는 대화들 — 등록 순서대로, 기본이 먼저.
149
- *
150
- * 화면은 이 목록으로 고르개를 낸다. 하나뿐이면 고르개를 내지 않는다(ADR-0072 A17 ①).
151
- */
152
- export function assistantModesForSubject(subject: string): ReadonlyArray<AssistantModeSpec> {
153
- const standing = [...modes.values()].filter(mode => (mode.subjects ?? []).includes(subject))
154
- return standing.sort((a, b) => Number((b.defaultFor ?? []).includes(subject)) - Number((a.defaultFor ?? []).includes(subject)))
111
+ /** The conversation a screen showing this kind of subject opens — one per subject (design/ai/screen-pattern.md §3). */
112
+ export function assistantModeForSubject(subject: string): AssistantModeSpec | undefined {
113
+ return [...modes.values()].find(mode => (mode.subjects ?? []).includes(subject))
155
114
  }
156
115
 
157
116
  /** Test only. */
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { clearAssistantModes, registerAssistantMode } from './registry'
10
10
  import { assistantModeView, resolveModeSettings } from './resolve'
11
+ import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
11
12
 
12
13
  /** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
13
14
  const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
@@ -32,7 +33,8 @@ it('★ the settings come from the registered mode', () => {
32
33
  const settings = resolveModeSettings({ kind: 'twin.space', target: 'S-12' }, t)
33
34
 
34
35
  expect(settings).toEqual({
35
- systemPrompt: 'you answer about one space',
36
+ /* The app's persona, then the rules every conversation follows (design/ai/screen-pattern.md §4). */
37
+ systemPrompt: conversationPrompt('you answer about one space'),
36
38
  /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
37
39
  toolCategories: ['twin-ops', 'assistant-workspace'],
38
40
  requireToolOnFirstTurn: true,
@@ -41,6 +43,13 @@ it('★ the settings come from the registered mode', () => {
41
43
  })
42
44
  })
43
45
 
46
+ it('★ every conversation carries the absence rule and the next-request rule once, after its persona', () => {
47
+ const { systemPrompt } = resolveModeSettings({ kind: 'twin.space' }, t)
48
+ expect(systemPrompt.startsWith('you answer about one space')).toBe(true)
49
+ expect(systemPrompt.split(ABSENCE_RULE)).toHaveLength(2)
50
+ expect(systemPrompt.split(NEXT_REQUEST_RULE)).toHaveLength(2)
51
+ })
52
+
44
53
  it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
45
54
  expect(() => resolveModeSettings({ kind: 'twin.nothing' }, t)).toThrow(/t\(ai-assistant.text.unknown-mode\)/)
46
55
  try {
@@ -6,6 +6,7 @@ import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
6
6
  * the server would answer differently.
7
7
  */
8
8
  import { getAssistantMode } from './registry'
9
+ import { conversationPrompt } from './screen-rules'
9
10
  import type { AssistantModeSlash } from './types'
10
11
 
11
12
  export type Translate = (key: string, params?: Record<string, any>) => string
@@ -65,7 +66,8 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
65
66
  if (!mode) throw unknownModeError(kind, t)
66
67
 
67
68
  return {
68
- systemPrompt: mode.systemPrompt,
69
+ /* The app's persona, with the rules every conversation follows (design/ai/screen-pattern.md §4). */
70
+ systemPrompt: conversationPrompt(mode.systemPrompt),
69
71
  /* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
70
72
  toolCategories: modeToolCategories(mode),
71
73
  requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
@@ -0,0 +1,39 @@
1
+ /*
2
+ * The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
3
+ */
4
+ import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules'
5
+
6
+ describe('the next request an answer names', () => {
7
+ it('★ the last line is taken off the reply and becomes the suggestion', () => {
8
+ expect(splitNextRequest('The fan has 3 blades.\n\nNext: 날개를 5개로 늘려 줘')).toEqual({
9
+ reply: 'The fan has 3 blades.',
10
+ suggestion: '날개를 5개로 늘려 줘'
11
+ })
12
+ })
13
+
14
+ it('quotes and bold around the line are not part of the request', () => {
15
+ expect(splitNextRequest('Done.\n**Next:** "재질을 바꿔 줘"').suggestion).toBe('재질을 바꿔 줘')
16
+ })
17
+
18
+ it('★ a Next: that is not the last line is part of the answer', () => {
19
+ const reply = 'Next: we read the figure.\nIt has 3 blades.'
20
+ expect(splitNextRequest(reply)).toEqual({ reply })
21
+ })
22
+
23
+ it('a reply with no such line has no suggestion', () => {
24
+ expect(splitNextRequest('It has 3 blades.')).toEqual({ reply: 'It has 3 blades.' })
25
+ })
26
+
27
+ it('a reply that is only the line keeps it - an empty answer is worse than an odd one', () => {
28
+ expect(splitNextRequest('Next: 날개를 늘려 줘')).toEqual({ reply: 'Next: 날개를 늘려 줘' })
29
+ })
30
+ })
31
+
32
+ describe('the prompt every conversation gets', () => {
33
+ it('★ the persona first, then the absence rule and the next-request rule', () => {
34
+ const prompt = conversationPrompt('You edit figures.')
35
+ expect(prompt.indexOf('You edit figures.')).toBe(0)
36
+ expect(prompt.indexOf(ABSENCE_RULE)).toBeGreaterThan(0)
37
+ expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
38
+ })
39
+ })
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The rules every conversation follows, whatever app declared it (operato-application design/ai/screen-pattern.md).
3
+ *
4
+ * Five apps carried their own copy of the absence rule, word for word or nearly. A rule every conversation needs
5
+ * belongs to the door that builds every conversation's prompt, so an app's prompt says only who the assistant is
6
+ * there and what it may not claim.
7
+ *
8
+ * Model-facing text is English (the AI standard's A11); the person's language comes from the request.
9
+ */
10
+
11
+ /** §4-2 — an absence or a refusal is a fact, with what the person can do. */
12
+ export const ABSENCE_RULE =
13
+ 'When a tool says something is absent or refuses, state that and what the person can do. ' +
14
+ 'Do not guess why, and do not describe our machinery - words like passed, delivered or loaded name our plumbing, not their situation.'
15
+
16
+ /** The line a reply ends with when a next request is natural (§4-1). */
17
+ const NEXT_PREFIX = 'Next:'
18
+
19
+ /** §4-1 — the reply names the next request, and the input offers it. */
20
+ export const NEXT_REQUEST_RULE =
21
+ `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
22
+ "the request the person is most likely to make next, written as they would type it, in the language they wrote in. " +
23
+ 'Offer only what this conversation can do with its tools. When nothing natural follows, leave the line out.'
24
+
25
+ /** A mode's persona with the rules every conversation follows. */
26
+ export function conversationPrompt(modePrompt: string): string {
27
+ return [modePrompt.trim(), ABSENCE_RULE, NEXT_REQUEST_RULE].join('\n\n')
28
+ }
29
+
30
+ /**
31
+ * The reply without its `Next:` line, and the request that line named.
32
+ *
33
+ * Only the last non-empty line counts: a `Next:` in the middle of an answer is part of the answer. The line is
34
+ * taken off before the reply is stored, so a conversation read later shows the answer alone.
35
+ */
36
+ export function splitNextRequest(reply: string): { reply: string; suggestion?: string } {
37
+ const text = String(reply ?? '')
38
+ const lines = text.replace(/\s+$/, '').split('\n')
39
+ const last = lines[lines.length - 1] ?? ''
40
+ const match = /^\s*(?:\*\*)?Next:(?:\*\*)?\s*(.+?)\s*$/.exec(last)
41
+ if (!match) return { reply: text }
42
+
43
+ const suggestion = match[1].replace(/^["“'`]+|["”'`]+$/g, '').trim()
44
+ const rest = lines.slice(0, -1).join('\n').replace(/\s+$/, '')
45
+ if (!suggestion || !rest) return { reply: text }
46
+ return { reply: rest, suggestion }
47
+ }
@@ -29,32 +29,15 @@ export interface AssistantModeSpec {
29
29
  kind: string
30
30
 
31
31
  /**
32
- * Which kinds of subject this conversation stands for — `['board', 'space']` (ADR-0072 A17 ①).
32
+ * Which kinds of subject this conversation is for — `['board']`, `['space']`.
33
33
  *
34
- * A screen says what it is showing (`context.subject`), and these say which conversations that
35
- * subject can carry. It is a list of types and not a predicate on purpose: a list can be counted
36
- * at boot and pinned by a test, while a function can only be run.
37
- *
38
- * On the twin modeller nothing could be asked about operations, because an open board pinned the
39
- * authoring conversation and that conversation opens one category. A conversation that also
40
- * stands for `board` is how the same screen carries both.
34
+ * A screen says what it is showing (`context.subject`) and opens the one conversation for it: a screen has one
35
+ * conversation (design/ai/screen-pattern.md §3). A list of types, so it can be counted at boot and pinned by a
36
+ * test; two modes naming one subject is a boot warning.
41
37
  */
42
38
  subjects?: string[]
43
39
 
44
- /**
45
- * The conversation a screen opens when the person has not chosen — one per subject type
46
- * (ADR-0072 A17 ②).
47
- *
48
- * Registration order must not decide this: whichever module booted last would silently become
49
- * the default. Exactly one mode declares each subject type, and the boot report says so when a
50
- * type has none or more than one.
51
- */
52
- defaultFor?: string[]
53
-
54
- /**
55
- * i18n key for what this conversation is called where a person chooses between them —
56
- * "이 그림 고치기", "이 현장 지금 어때". A mode a screen may offer needs one.
57
- */
40
+ /** i18n key for what this conversation is called in the conversation list. */
58
41
  labelKey?: string
59
42
  /** Who the assistant is here, and what it must not claim to do. The server's, not the caller's. */
60
43
  systemPrompt: string
@@ -118,16 +101,10 @@ export interface AssistantModeSpec {
118
101
  export interface AssistantModeWarning {
119
102
  /**
120
103
  * `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.
121
- * `assistant-mode-subject-without-default` — a subject type no conversation claims as its default.
122
- * `assistant-mode-subject-default-twice` — two conversations claim the same subject type.
123
- * `assistant-mode-offered-without-label` — a conversation a screen may offer with nothing to call it.
104
+ * `assistant-mode-subject-claimed-twice` — two conversations name the same subject type.
105
+ * `assistant-mode-entry-target-unknown` — a record the mode enters that no application registered.
124
106
  */
125
- code:
126
- | 'assistant-mode-slash-outside-tools'
127
- | 'assistant-mode-subject-without-default'
128
- | 'assistant-mode-subject-default-twice'
129
- | 'assistant-mode-offered-without-label'
130
- | 'assistant-mode-entry-target-unknown'
107
+ code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown'
131
108
  params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[]; target?: string }
132
109
  /** For a person reading the boot log; callers act on the code and params. */
133
110
  message: string