@things-factory/ai-assistant 10.1.47 → 10.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -0
  9. package/client/setup-assistant-dock.ts +109 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +110 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +118 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Standing the AI dock up in an application - one call in its bootstrap (design/ai/shared-dock.md §2-1).
3
+ *
4
+ * setupAssistantDock()
5
+ * registerAssistantHome({ ... }) // what the app's own conversation is about
6
+ * registerSubjectSurface('items', ...) // how a page's context.subject becomes a surface (if any)
7
+ *
8
+ * The dock sits in the aside bar above everything, closed at first; the header band's `forum` button opens
9
+ * it and carries how many conversations have something unread. Asks from other screens open it too.
10
+ */
11
+ import '@material/web/icon/icon.js'
12
+ import './components/assistant-dock.js'
13
+
14
+ import { LitElement, css, html, nothing } from 'lit'
15
+ import { customElement, property, state } from 'lit/decorators.js'
16
+
17
+ import { i18next } from '@operato/i18n'
18
+ import { appendViewpart, openOverlay, TOOL_POSITION, VIEWPART_LEVEL, VIEWPART_POSITION } from '@operato/layout'
19
+ import { store } from '@operato/shell'
20
+ import { APPEND_APP_TOOL } from '@things-factory/apptool-base/client/index.js'
21
+
22
+ import { ASSISTANT_DOCK_VIEWPART } from './components/assistant-dock.js'
23
+ import { startSessionActivityFeed, subscribeSessionActivity, unreadSessionCount } from './utils/assistant-activity.js'
24
+
25
+ @customElement('ox-assistant-dock-button')
26
+ export class AssistantDockButton extends LitElement {
27
+ static styles = css`
28
+ :host {
29
+ position: relative;
30
+ display: inline-flex;
31
+ cursor: pointer;
32
+ }
33
+
34
+ .badge {
35
+ position: absolute;
36
+ top: -4px;
37
+ right: -6px;
38
+ min-width: 14px;
39
+ height: 14px;
40
+ padding: 0 3px;
41
+ box-sizing: border-box;
42
+ border-radius: 7px;
43
+ background: var(--md-sys-color-error);
44
+ color: var(--md-sys-color-on-error);
45
+ font-size: 9px;
46
+ line-height: 14px;
47
+ text-align: center;
48
+ }
49
+ `
50
+
51
+ @property({ type: String }) viewpart = ASSISTANT_DOCK_VIEWPART
52
+ @state() private unread = 0
53
+
54
+ private unsubscribe?: () => void
55
+
56
+ connectedCallback() {
57
+ super.connectedCallback()
58
+ this.unread = unreadSessionCount()
59
+ this.unsubscribe = subscribeSessionActivity(() => (this.unread = unreadSessionCount()))
60
+ }
61
+
62
+ disconnectedCallback() {
63
+ this.unsubscribe?.()
64
+ super.disconnectedCallback()
65
+ }
66
+
67
+ /*
68
+ * Opens, never toggles: after a reload the viewpart can be marked shown while nothing is drawn, and a toggle
69
+ * then closed it - the first press did nothing (found on :3700, 2026-09-25). The dock's own ✕ closes it.
70
+ */
71
+ render() {
72
+ return html`
73
+ <md-icon
74
+ title=${i18next.t('ai-assistant.dock.open', { defaultValue: 'Ask AI' })}
75
+ @click=${() => openOverlay(this.viewpart, { backdrop: false })}
76
+ >forum</md-icon
77
+ >
78
+ ${this.unread ? html`<span class="badge">${this.unread}</span>` : nothing}
79
+ `
80
+ }
81
+ }
82
+
83
+ let installed = false
84
+
85
+ export function setupAssistantDock(options: { viewpart?: string } = {}): void {
86
+ if (installed) return
87
+ installed = true
88
+ const viewpart = options.viewpart ?? ASSISTANT_DOCK_VIEWPART
89
+
90
+ startSessionActivityFeed()
91
+
92
+ appendViewpart({
93
+ name: viewpart,
94
+ viewpart: { show: false, level: VIEWPART_LEVEL.TOPMOST, template: html`<ox-assistant-dock .viewpart=${viewpart}></ox-assistant-dock>` },
95
+ position: VIEWPART_POSITION.ASIDEBAR
96
+ })
97
+
98
+ store.dispatch({
99
+ type: APPEND_APP_TOOL,
100
+ tool: {
101
+ name: 'assistant-dock-open',
102
+ position: TOOL_POSITION.REAR,
103
+ template: html`<ox-assistant-dock-button .viewpart=${viewpart}></ox-assistant-dock-button>`
104
+ }
105
+ })
106
+
107
+ /* askAssistant() from any screen opens the dock. */
108
+ window.addEventListener('assistant-dock-open', () => openOverlay(viewpart, { backdrop: false }))
109
+ }
@@ -0,0 +1,122 @@
1
+ /*
2
+ * AI 세션 활동 구독 — **앱 전역 1회**. 도크가 닫혀 있어도(마운트되지 않아도) 새 소식을 안다.
3
+ *
4
+ * operato-twin 의 `twin-ai-activity.ts` 에서 올려 왔다(2026-09-25, design/ai/shared-dock.md §2-8) — 안 읽음은
5
+ * 모든 앱에서 같은 기능이다. `setupAssistantDock()` 이 부팅에서 한 번 연다.
6
+ *
7
+ * ── 왜 도크가 아니라 여기인가 ───────────────────────────────────────────────
8
+ * 도크는 asidebar viewpart 이고 닫혀 있으면 **렌더되지 않는다**(템플릿이 아예 안 붙는다). 그래서
9
+ * 도크 안에서 구독하면 닫아 둔 동안 아무 소식을 받지 못한다 — 배지를 띄울 수 없다.
10
+ * 구독은 부팅 시 한 번 열고, 도크·컨텍스트 밴드는 이 스토어를 본다.
11
+ *
12
+ * ── 종류에 따라 다르게 다룬다 ───────────────────────────────────────────────
13
+ * message — 새 메시지. 안 읽은 세션으로 표시(배지 올림). **내가 보낸 것은 올리지 않는다.**
14
+ * meta — 이름 변경 등. 목록 갱신만 알리고 배지는 올리지 않는다(소음 방지).
15
+ *
16
+ * 정확한 미읽음 **개수**는 세지 않는다 — 세션 수만 센다(서버 목록 정책과 같다).
17
+ */
18
+ import gql from 'graphql-tag'
19
+
20
+ import { client, gqlContext } from '@operato/graphql'
21
+
22
+
23
+ export interface SessionActivity {
24
+ sessionId: string
25
+ kind: 'message' | 'meta'
26
+ anchorType?: string
27
+ anchorId?: string
28
+ at: string
29
+ preview?: string
30
+ actorId?: string
31
+ }
32
+
33
+ type Listener = (activity: SessionActivity) => void
34
+
35
+ const listeners = new Set<Listener>()
36
+ /** 안 읽은 세션 id — 개수가 아니라 세션 단위. 도크에서 그 세션을 열면 지운다. */
37
+ const unreadSessions = new Set<string>()
38
+ let subscription: { unsubscribe(): void } | null = null
39
+
40
+ const ACTIVITY_SUBSCRIPTION = gql`
41
+ subscription MySessionActivity {
42
+ mySessionActivity {
43
+ sessionId
44
+ kind
45
+ anchorType
46
+ anchorId
47
+ at
48
+ preview
49
+ actorId
50
+ }
51
+ }
52
+ `
53
+
54
+
55
+ export function unreadSessionCount(): number {
56
+ return unreadSessions.size
57
+ }
58
+
59
+ export function isSessionUnread(sessionId: string): boolean {
60
+ return unreadSessions.has(sessionId)
61
+ }
62
+
63
+ /** 그 세션을 읽었다 — 도크가 세션을 열 때 호출(서버 markSessionRead 와 짝). */
64
+ export function clearSessionUnread(sessionId: string): void {
65
+ if (unreadSessions.delete(sessionId)) notify({ sessionId, kind: 'meta', at: new Date().toISOString() })
66
+ }
67
+
68
+ export function subscribeSessionActivity(fn: Listener): () => void {
69
+ listeners.add(fn)
70
+ return () => listeners.delete(fn)
71
+ }
72
+
73
+ function notify(activity: SessionActivity) {
74
+ listeners.forEach(fn => {
75
+ try {
76
+ fn(activity)
77
+ } catch {
78
+ /* 구독자 오류가 다른 구독자를 막지 않는다 */
79
+ }
80
+ })
81
+ }
82
+
83
+ /**
84
+ * 구독 시작 — 부팅에서 1회. 재호출은 무시(중복 구독 방지).
85
+ * 실패해도 앱을 막지 않는다 — 배지가 없을 뿐이고, 도크를 열면 목록을 직접 읽는다.
86
+ */
87
+ export function startSessionActivityFeed(): void {
88
+ if (subscription) return
89
+ try {
90
+ subscription = client
91
+ .subscribe({ query: ACTIVITY_SUBSCRIPTION, context: gqlContext() })
92
+ .subscribe({
93
+ next: ({ data }: any) => {
94
+ const activity: SessionActivity | undefined = data?.mySessionActivity
95
+ if (!activity?.sessionId) return
96
+ /* 자기 발신은 배지를 올리지 않는다 — 방금 내가 쓴 것이 안 읽음으로 뜨면 어색하다.
97
+ * 서버는 actorId(사용자 id)를 싣지만 클라는 내부 관리키를 판정에 쓰지 않는다 →
98
+ * 자기 발신 판정은 도크가 열려 있는 세션인지로 대신한다(그 세션은 이미 읽고 있다). */
99
+ if (activity.kind === 'message' && !isOpenSession(activity.sessionId)) {
100
+ unreadSessions.add(activity.sessionId)
101
+ }
102
+ notify(activity)
103
+ },
104
+ error: () => {
105
+ subscription = null // 재연결은 다음 호출에 맡긴다(자동 재시도 루프를 두지 않는다)
106
+ }
107
+ })
108
+ } catch {
109
+ subscription = null
110
+ }
111
+ }
112
+
113
+ /* 도크가 지금 열어 둔 세션 — 그 세션의 활동은 배지를 올리지 않는다(보고 있으므로). */
114
+ let openSessionId = ''
115
+ export function setOpenSession(sessionId: string): void {
116
+ openSessionId = sessionId || ''
117
+ if (openSessionId) clearSessionUnread(openSessionId)
118
+ }
119
+ function isOpenSession(sessionId: string): boolean {
120
+ return !!openSessionId && openSessionId === sessionId
121
+ }
122
+
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Asking the assistant from another screen - `askAssistant()` (design/ai/shared-dock.md §2-11).
3
+ *
4
+ * Generalised from operato-twin's promotion channel and operato-plant's ask box: a toast's "ask AI", a
5
+ * header ask box, a performance page carrying its context. The request waits here until the dock takes it,
6
+ * so a dock that is closed (not mounted) still gets it when it opens.
7
+ */
8
+ export interface AssistantAsk {
9
+ /** Sent as the next question. */
10
+ ask?: string
11
+ /** Put in the input, not sent. */
12
+ draft?: string
13
+ /** Ask in the open conversation instead of a new one. */
14
+ continueCurrent?: boolean
15
+ /** A new conversation's name. */
16
+ name?: string
17
+ /** Turns carried into the new conversation before the question. */
18
+ transcript?: { role: 'user' | 'ai'; content: string }[]
19
+ /** A note stored first: what the screen was showing when the person asked. */
20
+ context?: string
21
+ }
22
+
23
+ type Listener = () => void
24
+
25
+ let pending: AssistantAsk | undefined
26
+ const listeners = new Set<Listener>()
27
+
28
+ export function askAssistant(ask: AssistantAsk): void {
29
+ pending = ask
30
+ listeners.forEach(listener => listener())
31
+ window.dispatchEvent(new CustomEvent('assistant-dock-open'))
32
+ }
33
+
34
+ /** The dock takes the request once. */
35
+ export function takeAssistantAsk(): AssistantAsk | undefined {
36
+ const ask = pending
37
+ pending = undefined
38
+ return ask
39
+ }
40
+
41
+ export function subscribeAssistantAsk(listener: Listener): () => void {
42
+ listeners.add(listener)
43
+ return () => listeners.delete(listener)
44
+ }
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Proposal handlers - each proposal kind is applied by the package that declared its tool
3
+ * (design/ai/shared-dock.md §4). The dock has no kind-specific code: it finds the handler, runs it,
4
+ * and reports the outcome on every branch (ADR-0070). A kind nobody handles is `unavailable`.
5
+ */
6
+ import type { AssistantSurface } from './assistant-surface.js'
7
+
8
+ export type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'
9
+
10
+ export interface ProposalOutcome {
11
+ verdict: ProposalVerdict
12
+ errorCode?: string
13
+ applied?: number
14
+ missed?: number
15
+ /** A line for the conversation, besides the outcome itself (e.g. what was created). */
16
+ note?: string
17
+ }
18
+
19
+ export interface ProposalContext {
20
+ sessionId?: string
21
+ surface?: AssistantSurface
22
+ /** Asks the person before anything irreversible. `false` → the handler returns `declined`. */
23
+ confirm(options: { title: string; text: string; confirmText?: string; cancelText?: string; type?: 'warning' | 'info' }): Promise<boolean>
24
+ notify(level: 'info' | 'error', message: string): void
25
+ }
26
+
27
+ export type ProposalHandler = (proposal: any, context: ProposalContext) => Promise<ProposalOutcome>
28
+
29
+ const handlers = new Map<string, ProposalHandler>()
30
+
31
+ /** Registered by the package that declares the tool - `proposeAction` by twin, a figure candidate by figure-ui. */
32
+ export function registerProposalHandler(tool: string, handler: ProposalHandler): void {
33
+ handlers.set(tool, handler)
34
+ }
35
+
36
+ export function proposalHandlerOf(tool: string | undefined): ProposalHandler | undefined {
37
+ return tool ? handlers.get(tool) : undefined
38
+ }
39
+
40
+ /** Runs the proposal - never throws; a failure is a `refused` outcome with its message as the code. */
41
+ export async function runProposal(proposal: any, context: ProposalContext): Promise<ProposalOutcome> {
42
+ const handler = proposalHandlerOf(proposal?.tool)
43
+ if (!handler) return { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' }
44
+ try {
45
+ const outcome = await handler(proposal, context)
46
+ return outcome?.verdict ? outcome : { verdict: 'unavailable', errorCode: 'no-outcome' }
47
+ } catch (error) {
48
+ return { verdict: 'refused', errorCode: (error as Error)?.message || 'failed' }
49
+ }
50
+ }
@@ -0,0 +1,18 @@
1
+ /*
2
+ * Which actions a row of the conversation list offers this viewer - pure, so the rule is tested alone.
3
+ *
4
+ * The same hints the server gives on each inbox entry; the doors decide again:
5
+ * rename any participant (the name is the topic label; reversible)
6
+ * hide/unhide anyone, for their own list only (reversible)
7
+ * delete the creator (`mine`) (not reversible from a screen)
8
+ * visibility the owner (`owner`) (opening shows what is already written)
9
+ */
10
+ export function sessionActions(entry: { participant?: boolean; hidden?: boolean; mine?: boolean; owner?: boolean }) {
11
+ return {
12
+ rename: entry.participant !== false,
13
+ hide: !entry.hidden,
14
+ unhide: !!entry.hidden,
15
+ remove: !!entry.mine,
16
+ setVisibility: !!entry.owner
17
+ }
18
+ }
@@ -0,0 +1,167 @@
1
+ import { client } from '@operato/graphql'
2
+ import { i18next } from '@operato/i18n'
3
+ import gql from 'graphql-tag'
4
+
5
+ export { sessionActions } from './assistant-session-actions.js'
6
+
7
+ /**
8
+ * Managing the conversations on one anchor - list, rename, hide, delete, and who may read them.
9
+ *
10
+ * These were written once, inside operato-twin's dock (things-factory `b3d1081f4c`, `b44c986c07`,
11
+ * 2026-07-30), and every other dock (plant, figure, warehouse, ecs) had only the tabs. They are the
12
+ * framework's doors (`chatSessionInbox` · `renameAISession` · `hideAISession` · `deleteAISession` ·
13
+ * `setSessionVisibility`), so the screen for them belongs here too - one list every dock opens.
14
+ *
15
+ * Who may do what is the server's to decide; the entry carries hints only:
16
+ * rename any participant (the name is the topic label; reversible)
17
+ * hide/unhide anyone, for their own list only (reversible)
18
+ * delete the creator (`mine`) (not reversible from a screen)
19
+ * visibility the owner (`owner`) (opening shows what is already written)
20
+ */
21
+
22
+ export type SessionVisibility = 'open' | 'private'
23
+
24
+ /** One row of `chatSessionInbox`, as the list draws it. */
25
+ export interface AssistantSessionEntry {
26
+ id: string
27
+ name?: string
28
+ kind?: string
29
+ lastMessageAt?: string
30
+ lastMessagePreview?: string
31
+ previewRedacted?: boolean
32
+ unread?: boolean
33
+ participant?: boolean
34
+ /** I created it - only then may I delete it. */
35
+ mine?: boolean
36
+ /** I own it - only then may I change who reads it. */
37
+ owner?: boolean
38
+ /** Hidden from my own list (returned only with the hidden filter on). */
39
+ hidden?: boolean
40
+ visibility?: SessionVisibility | string
41
+ }
42
+
43
+ /** Legacy single filter - kept for callers written before the filter set. */
44
+ export type AssistantSessionFilter = 'all' | 'mine' | 'hidden'
45
+
46
+ /** What the manager narrows by (design/ai/shared-dock.md §2-6). Defaults: this kind, everyone's, any visibility, hidden out. */
47
+ export interface AssistantSessionFilters {
48
+ /** A kind (mode) to list; empty lists every kind on the anchor. */
49
+ kind?: string
50
+ whose?: 'all' | 'mine' | 'participant'
51
+ visibility?: 'all' | 'open' | 'private'
52
+ includeHidden?: boolean
53
+ unreadOnly?: boolean
54
+ }
55
+
56
+ export interface AssistantSessionQuery {
57
+ anchorType: string
58
+ anchorId: string
59
+ search?: string
60
+ filter?: AssistantSessionFilter
61
+ filters?: AssistantSessionFilters
62
+ limit?: number
63
+ offset?: number
64
+ }
65
+
66
+ export interface AssistantSessionPage {
67
+ entries: AssistantSessionEntry[]
68
+ /** How many pass the filter - so "load more" knows whether the end of the list is the end of the data. */
69
+ total: number
70
+ /** How many I hid - so a hidden conversation is never forgotten. */
71
+ hiddenCount: number
72
+ }
73
+
74
+ export interface AssistantSessionAdmin {
75
+ inbox(query: AssistantSessionQuery): Promise<AssistantSessionPage>
76
+ rename(sessionId: string, name: string): Promise<boolean>
77
+ hide(sessionId: string, hidden: boolean): Promise<boolean>
78
+ remove(sessionId: string): Promise<boolean>
79
+ setVisibility(sessionId: string, visibility: SessionVisibility): Promise<boolean>
80
+ }
81
+
82
+ /** The page size the server allows at most (`chatSessionInbox` clamps to 200). */
83
+ export const SESSION_PAGE_MAX = 200
84
+
85
+ async function mutate(mutation: any, variables: Record<string, unknown>, field: string): Promise<boolean> {
86
+ const result = await client.mutate({ mutation, variables })
87
+ if (result.errors?.length) throw new Error(result.errors[0].message)
88
+ return result.data?.[field] === true
89
+ }
90
+
91
+ /** The framework's session doors, as they are. */
92
+ export const assistantSessionAdmin: AssistantSessionAdmin = {
93
+ async inbox({ anchorType, anchorId, search, filter = 'all', filters = {}, limit = 50, offset = 0 }) {
94
+ const hidden = filters.includeHidden ?? filter === 'hidden'
95
+ const mine = filters.whose ? filters.whose === 'mine' : filter === 'mine'
96
+ const result = await client.query({
97
+ query: gql`
98
+ query ($t: String!, $i: String!, $h: Boolean, $q: String, $m: Boolean, $l: Int, $o: Int, $k: String, $p: Boolean, $v: String, $u: Boolean) {
99
+ hiddenSessionCount(anchorType: $t, anchorId: $i)
100
+ chatSessionInboxTotal(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u)
101
+ chatSessionInbox(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u, limit: $l, offset: $o) {
102
+ id
103
+ name
104
+ kind
105
+ lastMessageAt
106
+ lastMessagePreview
107
+ previewRedacted
108
+ unread
109
+ participant
110
+ mine
111
+ owner
112
+ hidden
113
+ visibility
114
+ }
115
+ }
116
+ `,
117
+ variables: {
118
+ t: anchorType,
119
+ i: anchorId,
120
+ /* Hidden ones only when asked - elsewhere they stay out of the list. */
121
+ h: hidden,
122
+ q: search?.trim() || null,
123
+ m: mine,
124
+ k: filters.kind || null,
125
+ p: filters.whose === 'participant' || null,
126
+ v: filters.visibility && filters.visibility !== 'all' ? filters.visibility : null,
127
+ u: filters.unreadOnly || null,
128
+ l: Math.min(SESSION_PAGE_MAX, Math.max(1, limit)),
129
+ o: Math.max(0, offset)
130
+ },
131
+ fetchPolicy: 'no-cache'
132
+ })
133
+
134
+ if (result.errors?.length) throw new Error(result.errors[0].message)
135
+ const entries = result.data?.chatSessionInbox
136
+ if (!Array.isArray(entries)) {
137
+ throw new Error(i18next.t('ai-assistant.text.sessions-unreadable', { defaultValue: 'could not load the conversation list.' }))
138
+ }
139
+
140
+ return {
141
+ /* With the hidden filter the server returns hidden ones alongside the rest, marked - the total counts both. */
142
+ entries,
143
+ total: result.data?.chatSessionInboxTotal ?? entries.length,
144
+ hiddenCount: result.data?.hiddenSessionCount ?? 0
145
+ }
146
+ },
147
+
148
+ rename(sessionId, name) {
149
+ return mutate(gql`mutation ($id: String!, $n: String!) { renameAISession(sessionId: $id, name: $n) }`, { id: sessionId, n: name }, 'renameAISession')
150
+ },
151
+
152
+ hide(sessionId, hidden) {
153
+ return mutate(gql`mutation ($id: String!, $h: Boolean) { hideAISession(sessionId: $id, hidden: $h) }`, { id: sessionId, h: hidden }, 'hideAISession')
154
+ },
155
+
156
+ remove(sessionId) {
157
+ return mutate(gql`mutation ($id: String!) { deleteAISession(sessionId: $id) }`, { id: sessionId }, 'deleteAISession')
158
+ },
159
+
160
+ setVisibility(sessionId, visibility) {
161
+ return mutate(
162
+ gql`mutation ($id: String!, $v: String!) { setSessionVisibility(sessionId: $id, visibility: $v) }`,
163
+ { id: sessionId, v: visibility },
164
+ 'setSessionVisibility'
165
+ )
166
+ }
167
+ }
@@ -17,6 +17,8 @@ export interface AssistantSessionTarget {
17
17
  scope: string
18
18
  anchorType: string
19
19
  anchorId: string
20
+ /** The conversation kind (mode) the tabs list - all readable conversations of it, not only mine (shared dock §2-6). */
21
+ kind?: string
20
22
  }
21
23
  export interface AssistantSessionTransport {
22
24
  list(target: AssistantSessionTarget): Promise<AssistantSession[]>
@@ -16,8 +16,12 @@ export const assistantSessionTransport: AssistantSessionTransport = {
16
16
  },
17
17
  async list(target) {
18
18
  const result = await client.query({
19
- query: gql`query($type:String!,$id:String!){chatSessionInbox(anchorType:$type,anchorId:$id,mineOnly:true,limit:100){id name kind}}`,
20
- variables: { type: target.anchorType, id: target.anchorId }, fetchPolicy: 'no-cache'
19
+ /*
20
+ * Every readable conversation of this kind on the anchor, not only mine - the same scope the manager
21
+ * shows by default (design/ai/shared-dock.md §2-6). Without a kind: every kind, as before.
22
+ */
23
+ query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind}}`,
24
+ variables: { type: target.anchorType, id: target.anchorId, kind: target.kind || null }, fetchPolicy: 'no-cache'
21
25
  })
22
26
  if (result.errors?.length) throw new Error(result.errors[0].message)
23
27
  if (!Array.isArray(result.data?.chatSessionInbox)) throw new Error(i18next.t('ai-assistant.text.sessions-unreadable', { defaultValue: 'could not load the conversation list.' }))