@ouraihub/aether-ui 0.0.0-stage → 0.1.1

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 (212) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +29 -0
  28. package/src/chat/ChatComposer.svelte +277 -0
  29. package/src/chat/ChatMessage.svelte +262 -0
  30. package/src/chat/ChatPanel.css +386 -0
  31. package/src/chat/ChatPanel.svelte +496 -0
  32. package/src/chat/ChatPanelApprovals.svelte +28 -0
  33. package/src/chat/ChatPanelComposer.svelte +317 -0
  34. package/src/chat/ChatPanelDialogs.svelte +242 -0
  35. package/src/chat/ChatPanelHeader.svelte +132 -0
  36. package/src/chat/ChatPanelMessages.svelte +166 -0
  37. package/src/chat/ChatPanelMode.svelte +190 -0
  38. package/src/chat/ChatPanelState.svelte.ts +1 -0
  39. package/src/chat/ChatPanelToolbar.svelte +425 -0
  40. package/src/chat/ChatSurface.svelte +168 -0
  41. package/src/chat/MessageItem.svelte +480 -0
  42. package/src/chat/ReasoningControl.svelte +286 -0
  43. package/src/chat/SkillPicker.svelte +133 -0
  44. package/src/chat/ToolCallItem.svelte +23 -0
  45. package/src/chat/UsageControl.svelte +216 -0
  46. package/src/chat/UsageDetails.svelte +76 -0
  47. package/src/chat/chat-attachments.svelte.ts +74 -0
  48. package/src/chat/chat-panel-history.svelte.ts +430 -0
  49. package/src/chat/chat-panel-props.ts +32 -0
  50. package/src/chat/index.ts +6 -0
  51. package/src/chat/run-session-history.ts +56 -0
  52. package/src/chat/run-session-types.ts +69 -0
  53. package/src/chat/run-session.svelte.ts +494 -0
  54. package/src/chat/test-support/chat-fixture.ts +178 -0
  55. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  56. package/src/chat/test-support/run-session-fixture.ts +112 -0
  57. package/src/components/ui/alert/alert-action.svelte +20 -0
  58. package/src/components/ui/alert/alert-description.svelte +23 -0
  59. package/src/components/ui/alert/alert-title.svelte +23 -0
  60. package/src/components/ui/alert/alert.svelte +43 -0
  61. package/src/components/ui/alert/index.ts +17 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  65. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  73. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  74. package/src/components/ui/alert-dialog/index.ts +40 -0
  75. package/src/components/ui/button/button.svelte +81 -0
  76. package/src/components/ui/button/index.ts +17 -0
  77. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  78. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  79. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  80. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  81. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  82. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  83. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  84. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  85. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  86. package/src/components/ui/dialog/dialog.svelte +7 -0
  87. package/src/components/ui/dialog/index.ts +34 -0
  88. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  89. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  90. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  91. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  92. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  93. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  96. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  97. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  98. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  99. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  100. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  101. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  102. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  103. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  104. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  105. package/src/components/ui/dropdown-menu/index.ts +54 -0
  106. package/src/components/ui/empty/empty-content.svelte +23 -0
  107. package/src/components/ui/empty/empty-description.svelte +23 -0
  108. package/src/components/ui/empty/empty-header.svelte +20 -0
  109. package/src/components/ui/empty/empty-media.svelte +41 -0
  110. package/src/components/ui/empty/empty-title.svelte +20 -0
  111. package/src/components/ui/empty/empty.svelte +23 -0
  112. package/src/components/ui/empty/index.ts +22 -0
  113. package/src/components/ui/field/field-content.svelte +20 -0
  114. package/src/components/ui/field/field-description.svelte +25 -0
  115. package/src/components/ui/field/field-error.svelte +58 -0
  116. package/src/components/ui/field/field-group.svelte +23 -0
  117. package/src/components/ui/field/field-label.svelte +25 -0
  118. package/src/components/ui/field/field-legend.svelte +24 -0
  119. package/src/components/ui/field/field-separator.svelte +35 -0
  120. package/src/components/ui/field/field-set.svelte +20 -0
  121. package/src/components/ui/field/field-title.svelte +20 -0
  122. package/src/components/ui/field/field.svelte +47 -0
  123. package/src/components/ui/field/index.ts +33 -0
  124. package/src/components/ui/input/index.ts +7 -0
  125. package/src/components/ui/input/input.svelte +48 -0
  126. package/src/components/ui/label/index.ts +7 -0
  127. package/src/components/ui/label/label.svelte +20 -0
  128. package/src/components/ui/select/index.ts +40 -0
  129. package/src/components/ui/select/select-content.svelte +45 -0
  130. package/src/components/ui/select/select-group-heading.svelte +21 -0
  131. package/src/components/ui/select/select-group.svelte +17 -0
  132. package/src/components/ui/select/select-item.svelte +43 -0
  133. package/src/components/ui/select/select-label.svelte +20 -0
  134. package/src/components/ui/select/select-portal.svelte +7 -0
  135. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  136. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  137. package/src/components/ui/select/select-separator.svelte +18 -0
  138. package/src/components/ui/select/select-trigger.svelte +29 -0
  139. package/src/components/ui/select/select-value.svelte +7 -0
  140. package/src/components/ui/select/select.svelte +11 -0
  141. package/src/components/ui/separator/index.ts +7 -0
  142. package/src/components/ui/separator/separator.svelte +21 -0
  143. package/src/components/ui/sonner/index.ts +1 -0
  144. package/src/components/ui/sonner/sonner.svelte +40 -0
  145. package/src/components/ui/switch/index.ts +7 -0
  146. package/src/components/ui/switch/switch.svelte +34 -0
  147. package/src/components/ui/textarea/index.ts +7 -0
  148. package/src/components/ui/textarea/textarea.svelte +22 -0
  149. package/src/context.ts +34 -0
  150. package/src/dev/Demo.svelte +47 -0
  151. package/src/dev/main.ts +7 -0
  152. package/src/i18n/en.ts +645 -0
  153. package/src/i18n/index.ts +28 -0
  154. package/src/i18n/zh-CN.ts +610 -0
  155. package/src/index.ts +10 -0
  156. package/src/logs/LogPanel.svelte +103 -0
  157. package/src/logs/LogSettings.svelte +58 -0
  158. package/src/logs/index.ts +5 -0
  159. package/src/logs/logger.ts +96 -0
  160. package/src/markdown.ts +8 -0
  161. package/src/mcp/McpClientEditor.svelte +204 -0
  162. package/src/mcp/McpClientSettings.svelte +418 -0
  163. package/src/mcp/McpServerSettings.svelte +363 -0
  164. package/src/mcp/config.ts +26 -0
  165. package/src/mcp/index.ts +3 -0
  166. package/src/mcp/input.ts +74 -0
  167. package/src/notify.svelte.ts +43 -0
  168. package/src/ports/contract.ts +174 -0
  169. package/src/ports/fake-optional.ts +81 -0
  170. package/src/ports/fake.ts +488 -0
  171. package/src/ports/index.ts +7 -0
  172. package/src/ports/search.ts +56 -0
  173. package/src/ports/skill-sources.ts +45 -0
  174. package/src/ports/test-support/fake-fixture.ts +63 -0
  175. package/src/providers/EndpointKeysDialog.svelte +357 -0
  176. package/src/providers/EndpointModels.svelte +311 -0
  177. package/src/providers/EndpointPanel.svelte +266 -0
  178. package/src/providers/ModelPickerDialog.svelte +230 -0
  179. package/src/providers/ModelPricing.svelte +125 -0
  180. package/src/providers/ModelSelect.svelte +448 -0
  181. package/src/providers/ModelSettingsDialog.svelte +388 -0
  182. package/src/providers/ProviderDialogs.svelte +120 -0
  183. package/src/providers/ProviderList.svelte +159 -0
  184. package/src/providers/ProvidersSettings.svelte +491 -0
  185. package/src/providers/RequestConfigDialog.svelte +278 -0
  186. package/src/providers/SecretInput.svelte +284 -0
  187. package/src/providers/draft.ts +355 -0
  188. package/src/providers/edit-session.ts +170 -0
  189. package/src/providers/index.ts +5 -0
  190. package/src/providers/reasoning.ts +55 -0
  191. package/src/providers/revision.svelte.ts +6 -0
  192. package/src/providers/test-support/providers-fixture.ts +120 -0
  193. package/src/search/SearchAdvancedTools.svelte +182 -0
  194. package/src/search/SearchProviderCard.svelte +195 -0
  195. package/src/search/SearchSettings.svelte +500 -0
  196. package/src/search/index.ts +13 -0
  197. package/src/search/settings-flow.ts +67 -0
  198. package/src/session.ts +20 -0
  199. package/src/skills/SkillAdd.svelte +380 -0
  200. package/src/skills/SkillAuthoring.svelte +430 -0
  201. package/src/skills/SkillEditor.svelte +104 -0
  202. package/src/skills/SkillPreviewDialog.svelte +136 -0
  203. package/src/skills/SkillsSettings.svelte +147 -0
  204. package/src/skills/error.ts +32 -0
  205. package/src/skills/index.ts +4 -0
  206. package/src/skills/test-support/skills-fixture.ts +139 -0
  207. package/src/styles/kit.css +137 -0
  208. package/src/styles/tokens.css +22 -0
  209. package/src/tauri/index.ts +298 -0
  210. package/src/utils.ts +22 -0
  211. package/src/web/index.ts +441 -0
  212. package/README.md +0 -3
@@ -0,0 +1,81 @@
1
+ import type { KitClientError } from './contract'
2
+ import type { McpClientPort, McpServerPort } from './contract'
3
+ import type { SearchPort } from './search'
4
+ import type { SkillSourcesPort } from './skill-sources'
5
+
6
+ export type FakeError = (
7
+ code: ConstructorParameters<typeof KitClientError>[0]['code'],
8
+ message: string,
9
+ details?: Record<string, string>,
10
+ ) => KitClientError
11
+
12
+ const STOPPED = { running: false, port: 0, url: '', toolCount: 0 }
13
+
14
+ export function mcpClientDefaults(options: Partial<McpClientPort>): McpClientPort {
15
+ return {
16
+ list: () => Promise.resolve([]),
17
+ save: () => Promise.resolve([]),
18
+ remove: () => Promise.resolve([]),
19
+ refresh: () => Promise.resolve([]),
20
+ setToolApproval: () => Promise.resolve(),
21
+ ...options,
22
+ }
23
+ }
24
+
25
+ export function mcpServerDefaults(options: Partial<McpServerPort>): McpServerPort {
26
+ return {
27
+ status: () => Promise.resolve(STOPPED),
28
+ tokens: () => Promise.resolve([]),
29
+ start: () => Promise.resolve(STOPPED),
30
+ stop: () => Promise.resolve(STOPPED),
31
+ setPort: (port) => Promise.resolve({ ...STOPPED, port }),
32
+ issueToken: (name, scopes) =>
33
+ Promise.resolve({
34
+ token: 'kit_fake',
35
+ id: 'fake-token',
36
+ name,
37
+ scopes: [...scopes],
38
+ createdAt: 0,
39
+ lastUsedAt: null,
40
+ revokedAt: null,
41
+ }),
42
+ revokeToken: () => Promise.resolve(),
43
+ scopes: () => Promise.resolve([]),
44
+ setScopes: () => Promise.resolve([]),
45
+ ...options,
46
+ }
47
+ }
48
+
49
+ export function searchDefaults(options: Partial<SearchPort>, error: FakeError): SearchPort {
50
+ const unavailable = () => Promise.reject(error('internal', 'Configure the fake search method'))
51
+ return {
52
+ settings: unavailable,
53
+ save: unavailable,
54
+ setDefault: unavailable,
55
+ savePreferences: unavailable,
56
+ saveKey: unavailable,
57
+ removeKey: unavailable,
58
+ reveal: unavailable,
59
+ check: unavailable,
60
+ search: unavailable,
61
+ fetch: unavailable,
62
+ ...options,
63
+ }
64
+ }
65
+
66
+ export function skillSourceDefaults(
67
+ options: Partial<SkillSourcesPort>,
68
+ error: FakeError,
69
+ ): SkillSourcesPort {
70
+ const unavailable = () =>
71
+ Promise.reject(error('internal', 'Configure the fake skill source method'))
72
+ return {
73
+ search: unavailable,
74
+ prepare: unavailable,
75
+ discover: unavailable,
76
+ prepareSystem: unavailable,
77
+ pickDirectory: unavailable,
78
+ pickSearchDirectory: unavailable,
79
+ ...options,
80
+ }
81
+ }
@@ -0,0 +1,488 @@
1
+ // In-memory RunClient, ConversationClient and desktop actions for tests and demos (docs/16, docs/22).
2
+ // Seed storage with initialConversations; override clients independently or omit a capability.
3
+ import { KitClientError } from './contract'
4
+ import {
5
+ mcpClientDefaults,
6
+ mcpServerDefaults,
7
+ searchDefaults,
8
+ skillSourceDefaults,
9
+ } from './fake-optional'
10
+
11
+ const clientError = (
12
+ code: ConstructorParameters<typeof KitClientError>[0]['code'],
13
+ message: string,
14
+ details: Record<string, string> = {},
15
+ ) => new KitClientError({ code, message, details })
16
+
17
+ import type {
18
+ ConversationClient,
19
+ DesktopActions,
20
+ RunClient,
21
+ KitPorts,
22
+ LogsPort,
23
+ McpClientPort,
24
+ McpServerPort,
25
+ ProvidersPort,
26
+ SkillsPort,
27
+ } from './contract'
28
+ import type {
29
+ Conversation,
30
+ LogLevel,
31
+ RunEvent,
32
+ RunSnapshot,
33
+ Turn,
34
+ } from '@ouraihub/aether-contracts'
35
+ import type { SearchPort } from './search'
36
+ import type { SkillSourcesPort } from './skill-sources'
37
+
38
+ type PortName = keyof KitPorts
39
+
40
+ export interface FakePortsOptions {
41
+ /** Initial stored conversations, copied to isolate the caller from fake storage. */
42
+ initialConversations?: readonly Conversation[]
43
+ conversations?: Partial<ConversationClient>
44
+ desktop?: Partial<DesktopActions>
45
+ logs?: Partial<LogsPort>
46
+ providers?: Partial<ProvidersPort>
47
+ chat?: Partial<RunClient>
48
+ search?: Partial<SearchPort>
49
+ skills?: Partial<SkillsPort>
50
+ skillSources?: Partial<SkillSourcesPort>
51
+ mcpClient?: Partial<McpClientPort>
52
+ mcpServer?: Partial<McpServerPort>
53
+ /** Ports to leave out, as a host without that feature would. */
54
+ omit?: readonly PortName[]
55
+ }
56
+
57
+ export function createFakePorts(options: FakePortsOptions = {}): KitPorts {
58
+ let level: LogLevel = 'INFO'
59
+ let sequence = 0
60
+ let conversationSequence = 0
61
+ const conversations = new Map<string, Conversation>(
62
+ (options.initialConversations ?? []).map((conversation) => [
63
+ conversation.summary.id,
64
+ structuredClone(conversation),
65
+ ]),
66
+ )
67
+ const nextId = (prefix: string): string => {
68
+ let id: string
69
+ do {
70
+ id = `${prefix}-${++sequence}`
71
+ } while (
72
+ [...conversations.values()].some((conversation) =>
73
+ conversation.turns.some((turn) => turn.id === id || turn.questionId === id),
74
+ )
75
+ )
76
+ return id
77
+ }
78
+ const storesConversations = !options.omit?.includes('conversations')
79
+ const runs = new Map<string, RunSnapshot>()
80
+ const terminals: string[] = []
81
+ const acceptRun = (
82
+ conversation: Conversation | null,
83
+ turn: Turn | null,
84
+ select: boolean,
85
+ onEvent: (event: RunEvent) => void,
86
+ signal?: AbortSignal,
87
+ ) => {
88
+ const run: RunSnapshot = {
89
+ runId: nextId('fake-run'),
90
+ conversationId: conversation?.summary.id ?? null,
91
+ turnId: turn?.id ?? null,
92
+ status: 'running',
93
+ cancelRequested: false,
94
+ }
95
+ runs.set(run.runId, run)
96
+ const cancel = () => {
97
+ run.cancelRequested = true
98
+ }
99
+ signal?.addEventListener('abort', cancel, { once: true })
100
+ // Leave an acceptance turn for callers to request cancellation or inspect the active run.
101
+ queueMicrotask(() =>
102
+ queueMicrotask(() => {
103
+ if (signal?.aborted) cancel()
104
+ if (turn && !run.cancelRequested) onEvent({ kind: 'text_delta', text: turn.text })
105
+ run.status = run.cancelRequested ? 'cancelled' : 'completed'
106
+ if (turn && conversation) {
107
+ turn.status = run.status
108
+ if (run.status === 'cancelled') turn.text = ''
109
+ if (select && run.status === 'completed')
110
+ conversation.summary = {
111
+ ...conversation.summary,
112
+ selectedTurnId: turn.id,
113
+ updatedAt: Date.now(),
114
+ }
115
+ }
116
+ signal?.removeEventListener('abort', cancel)
117
+ terminals.push(run.runId)
118
+ if (terminals.length > 128) {
119
+ const expired = terminals.shift()
120
+ if (expired !== undefined) runs.delete(expired)
121
+ }
122
+ onEvent({ kind: 'end', status: run.status })
123
+ }),
124
+ )
125
+ return { runId: run.runId, conversationId: run.conversationId, turnId: run.turnId }
126
+ }
127
+ const snapshot = (conversation: Conversation): Conversation => structuredClone(conversation)
128
+ const getConversation = (id: string) => {
129
+ const conversation = conversations.get(id)
130
+ if (!conversation)
131
+ throw clientError('not_found', `conversation ${id}`, { entity: 'conversation' })
132
+ if (conversation.turns.some((turn) => turn.status === 'running'))
133
+ throw clientError('conflict', 'conversation is active', { reason: 'active_run' })
134
+ return conversation
135
+ }
136
+ const getTurn = (conversationId: string, turnId: string) => {
137
+ const turn = getConversation(conversationId).turns.find((candidate) => candidate.id === turnId)
138
+ if (!turn) throw clientError('not_found', `turn ${turnId}`, { entity: 'turn' })
139
+ return turn
140
+ }
141
+ const conversationClient: ConversationClient = {
142
+ list: (scope?: string) =>
143
+ Promise.resolve(
144
+ [...conversations.values()]
145
+ .filter(({ summary }) => !scope || summary.scope === scope)
146
+ .map(({ summary }) => ({ ...summary })),
147
+ ),
148
+ get: async (id: string) => {
149
+ const conversation = conversations.get(id)
150
+ if (!conversation)
151
+ throw clientError('not_found', `conversation ${id}`, { entity: 'conversation' })
152
+ return snapshot(conversation)
153
+ },
154
+ remove: async (id: string) => {
155
+ getConversation(id)
156
+ conversations.delete(id)
157
+ },
158
+ rename: async (id: string, title: string) => {
159
+ const conversation = getConversation(id)
160
+ conversation.summary = { ...conversation.summary, title, updatedAt: Date.now() }
161
+ return { ...conversation.summary }
162
+ },
163
+ editTurn: async (
164
+ conversationId: string,
165
+ turnId: string,
166
+ text: string,
167
+ expectedText: string,
168
+ ) => {
169
+ const conversation = getConversation(conversationId)
170
+ const turn = getTurn(conversationId, turnId)
171
+ if (turn.text !== expectedText)
172
+ throw clientError('conflict', 'turn changed', { reason: 'text_changed' })
173
+ if (!text.trim() || Array.from(text).length > 24_000)
174
+ throw clientError('bad_request', 'invalid text', { field: 'text' })
175
+ if (
176
+ conversation.turns.some(
177
+ (candidate) => candidate.parentId === turnId || candidate.supersedesId === turnId,
178
+ )
179
+ )
180
+ throw clientError('conflict', 'turn has children', { reason: 'has_children' })
181
+ turn.text = text
182
+ turn.reasoningText = null
183
+ turn.contextUsage = null
184
+ conversation.summary.updatedAt = Date.now()
185
+ return snapshot(conversation)
186
+ },
187
+ removeTurn: async (conversationId: string, turnId: string) => {
188
+ // Same rule as both hosts (AutoNote docs/35 §3.3): reparent onto the successor, then delete.
189
+ const conversation = getConversation(conversationId)
190
+ const turn = getTurn(conversationId, turnId)
191
+ if (turn.status === 'running')
192
+ throw clientError('conflict', 'turn running', { reason: 'turn_running' })
193
+ // Turns are stored in creation order, which stands in for `seq`.
194
+ const at = conversation.turns.indexOf(turn)
195
+ const group = conversation.turns.filter(
196
+ (t) => t.kind === 'reply' && t.questionId === turn.questionId && t !== turn,
197
+ )
198
+ const sibling = group.find((t) => conversation.turns.indexOf(t) > at) ?? group.at(-1)
199
+ const successor = sibling?.id ?? turn.parentId
200
+ const removed = new Set([turnId])
201
+ for (const t of conversation.turns)
202
+ if (t.kind === 'translation' && removed.has(t.parentId ?? '')) removed.add(t.id)
203
+ conversation.turns = conversation.turns
204
+ .filter((t) => !removed.has(t.id))
205
+ .map((t) => ({
206
+ ...t,
207
+ parentId: removed.has(t.parentId ?? '') ? successor : t.parentId,
208
+ supersedesId: removed.has(t.supersedesId ?? '')
209
+ ? sibling && sibling.id !== t.id
210
+ ? sibling.id
211
+ : null
212
+ : t.supersedesId,
213
+ }))
214
+ if (removed.has(conversation.summary.selectedTurnId ?? '')) {
215
+ const rest = conversation.turns.filter((t) => t.kind === 'reply')
216
+ let head = successor ?? rest.filter((t) => t.parentId === null).at(-1)?.id ?? null
217
+ for (;;) {
218
+ const child = head === null ? undefined : rest.filter((t) => t.parentId === head).at(-1)
219
+ if (!child) break
220
+ head = child.id
221
+ }
222
+ conversation.summary.selectedTurnId = head
223
+ }
224
+ conversation.summary.updatedAt = Date.now()
225
+ return snapshot(conversation)
226
+ },
227
+ fork: async (conversationId: string, turnId: string) => {
228
+ const source = getConversation(conversationId)
229
+ const target = getTurn(conversationId, turnId)
230
+ const chain: Turn[] = []
231
+ const seen = new Set<string>()
232
+ let cursor: Turn | undefined = target
233
+ while (cursor) {
234
+ if (seen.has(cursor.id))
235
+ throw clientError('conflict', 'history cycle', { reason: 'history_cycle' })
236
+ seen.add(cursor.id)
237
+ chain.unshift(cursor)
238
+ if (!cursor.parentId) break
239
+ cursor = source.turns.find((turn) => turn.id === cursor?.parentId)
240
+ if (!cursor) throw clientError('not_found', 'history parent', { entity: 'turn' })
241
+ }
242
+ const ids = new Map<string, string>()
243
+ const questionIds = new Map<string, string>()
244
+ const turns = chain.map((turn) => {
245
+ const id = nextId('fake-turn')
246
+ ids.set(turn.id, id)
247
+ let questionId = questionIds.get(turn.questionId)
248
+ if (questionId === undefined) {
249
+ questionId = id
250
+ questionIds.set(turn.questionId, questionId)
251
+ }
252
+ return {
253
+ ...turn,
254
+ id,
255
+ questionId,
256
+ parentId: turn.parentId ? (ids.get(turn.parentId) ?? null) : null,
257
+ supersedesId: turn.supersedesId ? (ids.get(turn.supersedesId) ?? null) : null,
258
+ }
259
+ })
260
+ let id: string
261
+ do {
262
+ id = `fake-conversation-${++conversationSequence}`
263
+ } while (conversations.has(id))
264
+ const copy: Conversation = {
265
+ summary: {
266
+ ...source.summary,
267
+ id,
268
+ createdAt: Date.now(),
269
+ updatedAt: Date.now(),
270
+ selectedTurnId: ids.get(turnId) ?? null,
271
+ },
272
+ turns: structuredClone(turns),
273
+ }
274
+ conversations.set(id, copy)
275
+ return snapshot(copy)
276
+ },
277
+ selectTurn: async (conversationId: string, turnId: string) => {
278
+ const conversation = getConversation(conversationId)
279
+ getTurn(conversationId, turnId)
280
+ conversation.summary = {
281
+ ...conversation.summary,
282
+ selectedTurnId: turnId,
283
+ updatedAt: Date.now(),
284
+ }
285
+ return snapshot(conversation)
286
+ },
287
+ }
288
+ const ports: Required<Omit<KitPorts, 'search' | 'skillSources'>> = {
289
+ logs: {
290
+ tail: () => Promise.resolve([]),
291
+ ingest: (batch) => Promise.resolve({ accepted: batch.entries.length, rejected: 0 }),
292
+ getLevel: () => Promise.resolve({ level, available: true }),
293
+ setLevel: (next) => {
294
+ level = next
295
+ return Promise.resolve({ level, available: true })
296
+ },
297
+ ...options.logs,
298
+ },
299
+ desktop: {
300
+ openLogsDirectory: () => Promise.resolve(),
301
+ ...options.desktop,
302
+ },
303
+ conversations: { ...conversationClient, ...options.conversations },
304
+ providers: {
305
+ revealNeedsPassword: false,
306
+ keyStrategies: false,
307
+ revealKey: () =>
308
+ Promise.reject(clientError('internal', 'Configure the fake provider reveal method')),
309
+ list: () => Promise.resolve([]),
310
+ setEnabled: () => Promise.resolve([]),
311
+ save: () => Promise.resolve([]),
312
+ saveKey: () => Promise.resolve([]),
313
+ removeKey: () => Promise.resolve([]),
314
+ remove: () => Promise.resolve([]),
315
+ modelOptions: () => Promise.resolve([]),
316
+ probe: () =>
317
+ Promise.resolve({ probeUrl: '', models: [], error: null, suggestedProtocol: null }),
318
+ ...options.providers,
319
+ },
320
+ chat: {
321
+ start: async (request, onEvent, callOptions) => {
322
+ if (callOptions?.signal?.aborted) throw clientError('cancelled', 'cancelled')
323
+ const retry = request.retryTurnId != null || request.regenerate
324
+ const translation = request.translateTurnId != null
325
+ const action = request.retryTurnId != null || translation
326
+ if (
327
+ (retry && translation) ||
328
+ (action && (request.regenerate || request.webSearch === true)) ||
329
+ (request.replyToId != null && (retry || translation)) ||
330
+ (request.targetLanguage != null && !translation)
331
+ )
332
+ throw clientError('bad_request', 'invalid graph request', { field: 'action' })
333
+ if (request.images?.length && (retry || translation))
334
+ throw clientError('bad_request', 'images on action', {
335
+ field: 'images',
336
+ reason: 'action_conflict',
337
+ })
338
+ if (
339
+ request.purpose === 'writing' &&
340
+ (request.replyToId != null || action || request.targetLanguage != null)
341
+ )
342
+ throw clientError('bad_request', 'writing graph fields', { field: 'action' })
343
+ if (
344
+ (retry || translation || request.replyToId != null) &&
345
+ (!request.conversationId || !storesConversations)
346
+ )
347
+ throw clientError('bad_request', 'conversation required', { field: 'conversationId' })
348
+ const language = request.targetLanguage?.trim() ?? ''
349
+ if (translation && (!language || Array.from(language).length > 80))
350
+ throw clientError('bad_request', 'invalid language', { field: 'targetLanguage' })
351
+ if (!retry && !translation && !request.input.trim())
352
+ throw clientError('bad_request', 'empty input', { field: 'input' })
353
+ if (!storesConversations || request.purpose === 'writing')
354
+ return acceptRun(null, null, false, onEvent, callOptions?.signal)
355
+ let conversationId = request.conversationId
356
+ if (!conversationId) {
357
+ do {
358
+ conversationId = `fake-conversation-${++conversationSequence}`
359
+ } while (conversations.has(conversationId))
360
+ }
361
+ const conversation: Conversation = request.conversationId
362
+ ? getConversation(conversationId)
363
+ : {
364
+ summary: {
365
+ id: conversationId,
366
+ title: request.input.slice(0, 80),
367
+ scope: request.scope ?? null,
368
+ createdAt: Date.now(),
369
+ updatedAt: Date.now(),
370
+ selectedTurnId: null,
371
+ },
372
+ turns: [],
373
+ }
374
+ const selectedId =
375
+ conversation.summary.selectedTurnId ??
376
+ [...conversation.turns].reverse().find((turn) => turn.status === 'completed')?.id ??
377
+ null
378
+ const sourceId =
379
+ request.retryTurnId ?? request.translateTurnId ?? (retry ? selectedId : null)
380
+ const source = sourceId ? getTurn(conversationId, sourceId) : undefined
381
+ if ((retry || translation) && !source)
382
+ throw clientError('not_found', 'source turn', { entity: 'turn' })
383
+ if (request.replyToId) getTurn(conversationId, request.replyToId)
384
+ if (!retry && !translation && selectedId && !request.replyToId)
385
+ getTurn(conversationId, selectedId)
386
+ const model =
387
+ request.model ??
388
+ (source?.endpointId && source.modelId
389
+ ? { endpointId: source.endpointId, modelId: source.modelId }
390
+ : null)
391
+ if (source && !model) throw clientError('needs_config', 'source model unavailable')
392
+ if (source && model && options.providers?.modelOptions) {
393
+ const models = await ports.providers.modelOptions()
394
+ if (
395
+ !models.some(
396
+ (option) =>
397
+ option.endpointId === model.endpointId && option.modelId === model.modelId,
398
+ )
399
+ )
400
+ throw clientError('needs_config', 'source model unavailable')
401
+ }
402
+ if (callOptions?.signal?.aborted) throw clientError('cancelled', 'cancelled')
403
+ if (request.conversationId) getConversation(conversationId)
404
+ conversations.set(conversationId, conversation)
405
+ const turnId = nextId('fake-turn')
406
+ const translationSource = translation ? source : undefined
407
+ const retrySource = retry ? source : undefined
408
+ const input = translationSource
409
+ ? `Translate the following reply into ${language}. Return only the translation, preserving Markdown formatting.\n\n${translationSource.text}`
410
+ : retrySource
411
+ ? retrySource.input
412
+ : request.input
413
+ const turn: Turn = {
414
+ id: turnId,
415
+ parentId: translationSource
416
+ ? translationSource.id
417
+ : retrySource
418
+ ? retrySource.parentId
419
+ : (request.replyToId ?? selectedId),
420
+ questionId: retrySource ? retrySource.questionId : turnId,
421
+ supersedesId: retrySource ? retrySource.id : null,
422
+ kind: translationSource ? 'translation' : retrySource ? retrySource.kind : 'reply',
423
+ endpointId: model?.endpointId ?? null,
424
+ modelId: model?.modelId ?? null,
425
+ input,
426
+ images: translationSource
427
+ ? []
428
+ : retrySource
429
+ ? retrySource.images
430
+ : (request.images ?? []).map((image) => ({
431
+ mime: image.mime,
432
+ url: `data:${image.mime};base64,${image.data}`,
433
+ })),
434
+ generated: [],
435
+ text: `Fake reply: ${input}`,
436
+ status: 'running',
437
+ errorCode: null,
438
+ reasoningText: null,
439
+ reasoningEffort: request.reasoningEffort ?? source?.reasoningEffort ?? 'default',
440
+ usage: null,
441
+ contextUsage: {
442
+ tokens: input.length * 2 + 12,
443
+ contextWindow: null,
444
+ source: 'estimated',
445
+ endpointId: model?.endpointId ?? null,
446
+ modelId: model?.modelId ?? null,
447
+ },
448
+ }
449
+ conversation.turns.push(turn)
450
+ return acceptRun(conversation, turn, !retry, onEvent, callOptions?.signal)
451
+ },
452
+ cancel: async (runId) => {
453
+ const run = runs.get(runId)
454
+ if (run?.status === 'running') run.cancelRequested = true
455
+ },
456
+ approve: async (runId) => {
457
+ if (!runs.has(runId)) throw clientError('not_found', `run ${runId}`, { entity: 'run' })
458
+ throw clientError('conflict', 'no pending approval', { reason: 'approval_not_pending' })
459
+ },
460
+ getSnapshot: async (runId) => {
461
+ const run = runs.get(runId)
462
+ if (!run) throw clientError('not_found', `run ${runId}`, { entity: 'run' })
463
+ return { ...run }
464
+ },
465
+ ...options.chat,
466
+ },
467
+ skills: {
468
+ list: () => Promise.resolve([]),
469
+ get: (id) => Promise.reject(clientError('not_found', `skill ${id}`, { entity: 'skill' })),
470
+ preview: () =>
471
+ Promise.reject(clientError('bad_request', 'no package', { field: 'SKILL.md' })),
472
+ install: () => Promise.resolve([]),
473
+ save: () => Promise.resolve([]),
474
+ duplicate: () => Promise.resolve([]),
475
+ reorder: () => Promise.resolve([]),
476
+ remove: () => Promise.resolve([]),
477
+ ...options.skills,
478
+ },
479
+ mcpClient: mcpClientDefaults(options.mcpClient ?? {}),
480
+ mcpServer: mcpServerDefaults(options.mcpServer ?? {}),
481
+ }
482
+ const result: KitPorts = { ...ports }
483
+ if (options.search) result.search = searchDefaults(options.search, clientError)
484
+ if (options.skillSources)
485
+ result.skillSources = skillSourceDefaults(options.skillSources, clientError)
486
+ for (const name of options.omit ?? []) delete result[name]
487
+ return result
488
+ }
@@ -0,0 +1,7 @@
1
+ // Public contracts, client ports and test fakes (docs/16 §2, §6).
2
+ export type * from '@ouraihub/aether-contracts'
3
+ export * from './contract'
4
+ export type * from './search'
5
+ export type * from './skill-sources'
6
+ export { createFakePorts } from './fake'
7
+ export type { FakePortsOptions } from './fake'
@@ -0,0 +1,56 @@
1
+ import type { CallOptions } from './contract'
2
+
3
+ import type {
4
+ SearchCap,
5
+ SearchPreferences,
6
+ SearchSettingsDto,
7
+ SearchSave,
8
+ SearchResult,
9
+ SearchPage,
10
+ } from '@ouraihub/aether-contracts'
11
+ export type {
12
+ SearchCap,
13
+ SearchKey,
14
+ SearchPreferences,
15
+ SearchProvider,
16
+ SearchSettingsDto,
17
+ SearchSave,
18
+ SearchResult,
19
+ SearchHit,
20
+ SearchPage,
21
+ } from '@ouraihub/aether-contracts'
22
+
23
+ export interface SearchPort {
24
+ revealNeedsPassword?: boolean
25
+ settings(capability: SearchCap, options?: CallOptions): Promise<SearchSettingsDto>
26
+ save(input: SearchSave, options?: CallOptions): Promise<SearchSettingsDto>
27
+ setDefault(
28
+ capability: SearchCap,
29
+ provider: string,
30
+ options?: CallOptions,
31
+ ): Promise<SearchSettingsDto>
32
+ savePreferences(input: SearchPreferences, options?: CallOptions): Promise<SearchPreferences>
33
+ saveKey(
34
+ capability: SearchCap,
35
+ provider: string,
36
+ value: string,
37
+ keyId?: string,
38
+ options?: CallOptions,
39
+ ): Promise<SearchSettingsDto>
40
+ removeKey(
41
+ capability: SearchCap,
42
+ provider: string,
43
+ keyId: string,
44
+ options?: CallOptions,
45
+ ): Promise<SearchSettingsDto>
46
+ reveal(
47
+ capability: SearchCap,
48
+ provider: string,
49
+ keyId: string,
50
+ currentPassword?: string,
51
+ options?: CallOptions,
52
+ ): Promise<string>
53
+ check(capability: SearchCap, provider: string, options?: CallOptions): Promise<{ count: number }>
54
+ search(query: string, options?: CallOptions): Promise<SearchResult>
55
+ fetch(url: string, options?: CallOptions): Promise<SearchPage>
56
+ }
@@ -0,0 +1,45 @@
1
+ import type { ModelRef, RunAccepted, RunEvent, Source } from '@ouraihub/aether-contracts'
2
+ import type { CallOptions } from './contract'
3
+
4
+ export interface SkillCandidate {
5
+ name: string
6
+ description: string
7
+ url: string
8
+ origin: string
9
+ }
10
+ export interface SystemSkillCandidate {
11
+ name: string
12
+ path: string
13
+ origin: string
14
+ }
15
+ export interface PreparedSkill {
16
+ source: Source
17
+ origin: string
18
+ ignored?: string[]
19
+ }
20
+ /** A multi-turn skill-creator workspace; its package is installed only through preview + digest. */
21
+ export interface SkillAuthoringPort {
22
+ create(options?: CallOptions): Promise<string>
23
+ send(
24
+ sessionId: string,
25
+ input: string,
26
+ model: ModelRef,
27
+ onEvent: (event: RunEvent) => void,
28
+ options?: CallOptions,
29
+ ): Promise<RunAccepted>
30
+ approve(sessionId: string, runId: string, callId: string, allow: boolean): Promise<void>
31
+ cancel(sessionId: string, runId: string): Promise<void>
32
+ prepare(sessionId: string, options?: CallOptions): Promise<PreparedSkill>
33
+ close(sessionId: string): Promise<void>
34
+ }
35
+
36
+ /** Optional acquisition capabilities; missing desktop actions never appear in Web hosts. */
37
+ export interface SkillSourcesPort {
38
+ search(query: string, options?: CallOptions): Promise<SkillCandidate[]>
39
+ prepare(url: string, options?: CallOptions): Promise<PreparedSkill>
40
+ discover(options?: CallOptions): Promise<SystemSkillCandidate[]>
41
+ prepareSystem(path: string, options?: CallOptions): Promise<PreparedSkill>
42
+ pickDirectory(options?: CallOptions): Promise<PreparedSkill | null>
43
+ pickSearchDirectory(options?: CallOptions): Promise<SystemSkillCandidate[] | null>
44
+ authoring?: SkillAuthoringPort
45
+ }