@alia.onl/sdk 15.8.0 → 19.1.0

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 (142) hide show
  1. package/CHANGELOG.md +262 -13
  2. package/README.md +223 -151
  3. package/package.json +16 -6
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +4 -4
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +98 -35
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +34 -15
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +141 -0
  91. package/src/runtime/app-assertion.ts +53 -0
  92. package/src/runtime/attachment-utils.ts +96 -0
  93. package/src/runtime/composer-draft-store.ts +270 -0
  94. package/src/runtime/config.tsx +186 -0
  95. package/src/runtime/conversation-store.ts +49 -0
  96. package/src/runtime/conversations.ts +557 -0
  97. package/src/runtime/error-utils.ts +104 -0
  98. package/src/runtime/host-tools.ts +140 -0
  99. package/src/runtime/index.ts +113 -0
  100. package/src/runtime/message-history.ts +124 -0
  101. package/src/runtime/model.ts +39 -0
  102. package/src/runtime/oxy-files.ts +37 -0
  103. package/src/runtime/power-levels.ts +66 -0
  104. package/src/runtime/query-keys.ts +34 -0
  105. package/src/runtime/random-uuid.ts +25 -0
  106. package/src/runtime/routes.ts +51 -0
  107. package/src/runtime/sharing.ts +165 -0
  108. package/src/runtime/sse-frame-reader.ts +88 -0
  109. package/src/runtime/stream-events.ts +146 -0
  110. package/src/runtime/stream-outcome.ts +21 -0
  111. package/src/runtime/thought-store.ts +103 -0
  112. package/src/runtime/thread/agent-activity.ts +261 -0
  113. package/src/runtime/thread/automation-labels.ts +79 -0
  114. package/src/runtime/thread/conversation-export.ts +135 -0
  115. package/src/runtime/thread/message-days.ts +120 -0
  116. package/src/runtime/thread/message-processor.ts +93 -0
  117. package/src/runtime/thread/task-utils.ts +11 -0
  118. package/src/runtime/thread/thought-utils.ts +752 -0
  119. package/src/runtime/thread/thread-history.ts +139 -0
  120. package/src/runtime/thread/timeline.ts +202 -0
  121. package/src/runtime/thread/title-tags.ts +8 -0
  122. package/src/runtime/thread/tool-cards.ts +383 -0
  123. package/src/runtime/turn-failure.ts +108 -0
  124. package/src/runtime/types.ts +148 -0
  125. package/src/runtime/usage-limit-error.ts +44 -0
  126. package/src/runtime/use-agent-activity.ts +80 -0
  127. package/src/runtime/use-conversation-stream.ts +1433 -0
  128. package/src/runtime/use-conversation-thread.ts +556 -0
  129. package/src/runtime/use-start-conversation.ts +107 -0
  130. package/src/runtime/use-thread-history.ts +64 -0
  131. package/src/runtime/use-thread-search.ts +95 -0
  132. package/src/runtime/use-turn-edit.ts +99 -0
  133. package/src/tools.ts +2 -2
  134. package/src/types.ts +18 -47
  135. package/src/components/AliaChatMessageList.tsx +0 -532
  136. package/src/components/ThinkingBlock.tsx +0 -43
  137. package/src/hooks/useAliaChat.ts +0 -573
  138. package/src/lib/catalogue.ts +0 -236
  139. package/src/lib/chat-stream.ts +0 -655
  140. package/src/lib/chat-transport.ts +0 -98
  141. package/src/lib/delegation.ts +0 -34
  142. package/src/lib/work-log-copy.ts +0 -61
@@ -13,7 +13,11 @@
13
13
  * `session.accepted` carries `seq`; a resume replays from `afterSeq`.
14
14
  */
15
15
 
16
- export const VOICE_PROTOCOL_VERSION = 1;
16
+ /**
17
+ * 2: every transcript and tool event names `messageId`, the message it belongs
18
+ * to as it is stored.
19
+ */
20
+ export const VOICE_PROTOCOL_VERSION = 2;
17
21
 
18
22
  export const VOICE_SESSION_PATH = '/v1/voice/session';
19
23
 
@@ -81,7 +85,8 @@ const CLOSE_REASONS: ReadonlySet<string> = new Set<VoiceCloseReason>([
81
85
  export type VoiceRole = 'user' | 'assistant';
82
86
 
83
87
  export type VoiceClientEvent =
84
- | { type: 'auth'; token: string }
88
+ /** `appAssertion`: the host's Oxy app assertion (ADR 0021); a socket cannot carry the header. */
89
+ | { type: 'auth'; token: string; appAssertion?: string }
85
90
  | {
86
91
  type: 'session.start';
87
92
  protocol: number;
@@ -107,17 +112,20 @@ export type VoiceServerEvent =
107
112
  | { type: 'input.ready' }
108
113
  | { type: 'user.speech_started'; itemId: string }
109
114
  | { type: 'user.speech_stopped'; itemId: string }
110
- | { type: 'transcript.delta'; role: VoiceRole; itemId: string; text: string }
111
- | { type: 'transcript.done'; role: VoiceRole; itemId: string; text: string }
115
+ /**
116
+ * `messageId` is the message the text belongs to, the id it is stored and
117
+ * announced under: an assistant turn's items are one message.
118
+ */
119
+ | { type: 'transcript.delta'; role: VoiceRole; messageId: string; itemId: string; text: string }
120
+ | { type: 'transcript.done'; role: VoiceRole; messageId: string; itemId: string; text: string }
112
121
  | { type: 'assistant.audio_done'; itemId: string }
113
122
  | {
114
123
  type: 'assistant.done';
115
124
  itemId: string | null;
116
125
  status: 'completed' | 'cancelled' | 'incomplete' | 'failed';
117
126
  }
118
- | { type: 'tool.started'; callId: string; name: string }
119
- | { type: 'tool.finished'; callId: string; name: string; ok: boolean }
120
- | { type: 'approval.requested'; toolName: string }
127
+ | { type: 'tool.started'; messageId: string; callId: string; name: string }
128
+ | { type: 'tool.finished'; messageId: string; callId: string; name: string; ok: boolean }
121
129
  | { type: 'messages.saved'; conversationId: string; messageIds: string[] }
122
130
  | { type: 'session.warning'; code: 'ending-soon'; remainingMs: number }
123
131
  | { type: 'session.reconnecting' }
@@ -196,9 +204,17 @@ export function parseVoiceServerEvent(raw: string): ReceivedVoiceEvent | null {
196
204
  case 'transcript.delta':
197
205
  case 'transcript.done':
198
206
  return (event.role === 'user' || event.role === 'assistant') &&
207
+ isString(event.messageId) &&
199
208
  isString(event.itemId) &&
200
209
  isString(event.text)
201
- ? { type: event.type, role: event.role, itemId: event.itemId, text: event.text, ...seq }
210
+ ? {
211
+ type: event.type,
212
+ role: event.role,
213
+ messageId: event.messageId,
214
+ itemId: event.itemId,
215
+ text: event.text,
216
+ ...seq,
217
+ }
202
218
  : null;
203
219
  case 'assistant.done':
204
220
  return event.itemId === null || isString(event.itemId)
@@ -215,23 +231,26 @@ export function parseVoiceServerEvent(raw: string): ReceivedVoiceEvent | null {
215
231
  }
216
232
  : null;
217
233
  case 'tool.started':
218
- return isString(event.callId) && isString(event.name)
219
- ? { type: 'tool.started', callId: event.callId, name: event.name, ...seq }
234
+ return isString(event.messageId) && isString(event.callId) && isString(event.name)
235
+ ? {
236
+ type: 'tool.started',
237
+ messageId: event.messageId,
238
+ callId: event.callId,
239
+ name: event.name,
240
+ ...seq,
241
+ }
220
242
  : null;
221
243
  case 'tool.finished':
222
- return isString(event.callId) && isString(event.name)
244
+ return isString(event.messageId) && isString(event.callId) && isString(event.name)
223
245
  ? {
224
246
  type: 'tool.finished',
247
+ messageId: event.messageId,
225
248
  callId: event.callId,
226
249
  name: event.name,
227
250
  ok: event.ok === true,
228
251
  ...seq,
229
252
  }
230
253
  : null;
231
- case 'approval.requested':
232
- return isString(event.toolName)
233
- ? { type: 'approval.requested', toolName: event.toolName, ...seq }
234
- : null;
235
254
  case 'messages.saved':
236
255
  return isString(event.conversationId) && Array.isArray(event.messageIds)
237
256
  ? {
@@ -8,12 +8,12 @@ import {
8
8
  } from '@oxy.so/bloom/icons';
9
9
  import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/task-list';
10
10
  import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
11
- import type { ToolInvocation } from '../types';
11
+ import type { ToolInvocation } from '../runtime/types';
12
12
  import { extractCitationSources } from './citations';
13
13
  import { domainOf } from './domain';
14
14
  import { getToolDoneLabel, getToolPillLabel } from './task-labels';
15
15
  import { toolCallStatus } from './tool-call-status';
16
- import { englishWorkLog, type Translate } from './work-log-copy';
16
+ import { englishCopy, type Translate } from './copy';
17
17
 
18
18
  /**
19
19
  * A turn's tool calls, as the two Bloom logs draw them.
@@ -32,7 +32,7 @@ import { englishWorkLog, type Translate } from './work-log-copy';
32
32
 
33
33
  /** What a host can swap: the words, and where a site's icon is served from. */
34
34
  export interface WorkLogOptions {
35
- /** Translator for the log's words (see `work-log-copy.ts`). Default: English. */
35
+ /** Translator for the log's words (see `copy.ts`). Default: English. */
36
36
  t?: Translate;
37
37
  /** The URL of a site's cached icon, or nothing for Bloom's quiet dot. */
38
38
  faviconUrl?: (url: string) => string | null | undefined;
@@ -154,7 +154,7 @@ function webStep(
154
154
  live: LiveResearch | undefined,
155
155
  options: WorkLogOptions,
156
156
  ): MappedStep {
157
- const t = options.t ?? englishWorkLog;
157
+ const t = options.t ?? englishCopy;
158
158
  const arrived = inv.state !== 'partial-call';
159
159
  const ok = succeeded(inv);
160
160
  const res = resultOf(inv);
@@ -256,7 +256,7 @@ export function webSearchLog(
256
256
  research: LiveResearch | undefined,
257
257
  options: WorkLogOptions = {},
258
258
  ): WebSearchLog {
259
- const t = options.t ?? englishWorkLog;
259
+ const t = options.t ?? englishCopy;
260
260
  const web = (invocations ?? []).filter(isWebInvocation);
261
261
  const mapped = web.map((inv) => webStep(inv, research, options));
262
262
 
@@ -333,7 +333,7 @@ export function taskListLog(
333
333
  live: boolean,
334
334
  options: WorkLogOptions = {},
335
335
  ): TaskListLog {
336
- const t = options.t ?? englishWorkLog;
336
+ const t = options.t ?? englishCopy;
337
337
  let revealed = 0;
338
338
  let open = true;
339
339
  const tasks = (invocations ?? [])
@@ -0,0 +1,151 @@
1
+ import { useOxy } from '@oxy.so/services/ui/client';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { useAliaApi } from './api';
4
+ import { aliaQueryKeys } from './query-keys';
5
+ import { API_ROUTES } from './routes';
6
+
7
+ /**
8
+ * The agent a chat is opened on, as `GET /v1/agents/:id/profile` answers it
9
+ * (`docs/agent-embedding.md` §1). Alia herself has no profile: she is the lead when no agent is
10
+ * named.
11
+ */
12
+ export interface AliaAgentProfile {
13
+ id: string;
14
+ name: string;
15
+ handle?: string;
16
+ /** Its look, as Oxy stores a character (JSON); drawn through `readAgentCharacter`. */
17
+ character?: unknown;
18
+ color?: string;
19
+ description?: string;
20
+ /** What the empty chat says; omitted, the chat's own greeting. */
21
+ greeting?: string;
22
+ /** The lines offered over the composer of an empty chat. */
23
+ starters: AliaAgentStarter[];
24
+ access?: 'private' | 'public';
25
+ /** Which of Alia's features this agent gates behind an app's own check. */
26
+ features: Array<{ key: string; gated: boolean }>;
27
+ }
28
+
29
+ export interface AliaAgentStarter {
30
+ title: string;
31
+ prompt: string;
32
+ }
33
+
34
+ /** One of Alia's features, for one agent and the person present (`GET /v1/agents/:id/features`). */
35
+ export interface AliaAgentFeature {
36
+ key: string;
37
+ state: 'available' | 'locked';
38
+ /** How many uses are left, when the publishing app counts them. */
39
+ remaining?: number;
40
+ }
41
+
42
+ /** The feature keys the chat itself reads: the attach menu's two rows. */
43
+ export const ALIA_FEATURES = {
44
+ documents: 'attachments.documents',
45
+ images: 'attachments.images',
46
+ } as const;
47
+
48
+ const FIVE_MINUTES = 1000 * 60 * 5;
49
+
50
+ /** A string, or nothing: the profile is read field by field rather than cast. */
51
+ const text = (value: unknown): string | undefined =>
52
+ typeof value === 'string' && value !== '' ? value : undefined;
53
+
54
+ /** The profile the server sent, kept only where it has the shape the contract names. */
55
+ export function readAgentProfile(value: unknown): AliaAgentProfile | null {
56
+ if (value === null || typeof value !== 'object') return null;
57
+ const raw = value as Record<string, unknown>;
58
+ const id = text(raw.id);
59
+ const name = text(raw.name);
60
+ if (id === undefined || name === undefined) return null;
61
+ const starters = Array.isArray(raw.starters)
62
+ ? raw.starters.flatMap((starter): AliaAgentStarter[] => {
63
+ if (starter === null || typeof starter !== 'object') return [];
64
+ const { title, prompt } = starter as Record<string, unknown>;
65
+ const line = text(prompt);
66
+ return line === undefined ? [] : [{ title: text(title) ?? line, prompt: line }];
67
+ })
68
+ : [];
69
+ const features = Array.isArray(raw.features)
70
+ ? raw.features.flatMap((feature): Array<{ key: string; gated: boolean }> => {
71
+ if (feature === null || typeof feature !== 'object') return [];
72
+ const { key, gated } = feature as Record<string, unknown>;
73
+ return typeof key === 'string' ? [{ key, gated: gated === true }] : [];
74
+ })
75
+ : [];
76
+ return {
77
+ id,
78
+ name,
79
+ handle: text(raw.handle),
80
+ character: raw.character ?? undefined,
81
+ color: text(raw.color),
82
+ description: text(raw.description),
83
+ greeting: text(raw.greeting),
84
+ starters,
85
+ access: raw.access === 'public' || raw.access === 'private' ? raw.access : undefined,
86
+ features,
87
+ };
88
+ }
89
+
90
+ /** The feature states the server sent; an entry of any other shape is not a state. */
91
+ export function readAgentFeatures(value: unknown): AliaAgentFeature[] {
92
+ const list =
93
+ value !== null && typeof value === 'object'
94
+ ? (value as { features?: unknown }).features
95
+ : undefined;
96
+ if (!Array.isArray(list)) return [];
97
+ return list.flatMap((feature): AliaAgentFeature[] => {
98
+ if (feature === null || typeof feature !== 'object') return [];
99
+ const { key, state, remaining } = feature as Record<string, unknown>;
100
+ if (typeof key !== 'string' || (state !== 'available' && state !== 'locked')) return [];
101
+ return [{ key, state, ...(typeof remaining === 'number' ? { remaining } : {}) }];
102
+ });
103
+ }
104
+
105
+ /** The profile of the agent `agentId`, or nothing for Alia (no id) and while it loads. */
106
+ export function useAgentProfile(agentId: string | undefined) {
107
+ const { isAuthenticated } = useOxy();
108
+ const api = useAliaApi();
109
+ return useQuery({
110
+ queryKey: aliaQueryKeys.agents.profile(agentId ?? ''),
111
+ queryFn: async () =>
112
+ readAgentProfile((await api.get<unknown>(API_ROUTES.agents.profile(agentId ?? ''))).data),
113
+ enabled: agentId !== undefined && isAuthenticated,
114
+ staleTime: FIVE_MINUTES,
115
+ retry: 1,
116
+ });
117
+ }
118
+
119
+ /**
120
+ * What of Alia's features the agent `agentId` offers the person present. Alia (no id) gates
121
+ * nothing, so she has no answer to wait for; see `featureRows` for what the chat makes of it.
122
+ */
123
+ export function useAgentFeatures(agentId: string | undefined) {
124
+ const { isAuthenticated } = useOxy();
125
+ const api = useAliaApi();
126
+ return useQuery({
127
+ queryKey: aliaQueryKeys.agents.features(agentId ?? ''),
128
+ queryFn: async () =>
129
+ readAgentFeatures((await api.get<unknown>(API_ROUTES.agents.features(agentId ?? ''))).data),
130
+ enabled: agentId !== undefined && isAuthenticated,
131
+ // A gate's state moves with the person's plan in another app: read it each time the chat opens.
132
+ staleTime: 0,
133
+ retry: 1,
134
+ });
135
+ }
136
+
137
+ /** How an attachment feature is offered: attached, an upsell, or not at all. */
138
+ export type FeatureOffer = 'available' | 'locked' | 'absent';
139
+
140
+ /**
141
+ * What the attach menu offers for `key`. Alia's own chat (`features === undefined`) offers every
142
+ * attachment; an agent offers what its feature answer lists — `locked` is still offered, as the
143
+ * host's upsell — and nothing it does not list.
144
+ */
145
+ export function featureOffer(
146
+ features: readonly AliaAgentFeature[] | undefined,
147
+ key: string,
148
+ ): FeatureOffer {
149
+ if (features === undefined) return 'available';
150
+ return features.find((feature) => feature.key === key)?.state ?? 'absent';
151
+ }
@@ -0,0 +1,141 @@
1
+ import { useOxy } from '@oxy.so/services/ui/client';
2
+ import { useMemo } from 'react';
3
+ import {
4
+ type AppAssertionHeaders,
5
+ NO_APP_ASSERTION,
6
+ useAppAssertionHeaders,
7
+ } from './app-assertion';
8
+ import { useAliaRuntime } from './config';
9
+
10
+ /**
11
+ * A refusal or failure from Alia's API, shaped so the helpers in
12
+ * `error-utils.ts` read it: `status`, and the parsed body under
13
+ * `response.data`.
14
+ */
15
+ export class AliaApiError extends Error {
16
+ readonly status: number;
17
+ readonly response: { status: number; data: unknown };
18
+
19
+ constructor(status: number, data: unknown, message?: string) {
20
+ super(message ?? `Alia request failed with status ${status}.`);
21
+ this.name = 'AliaApiError';
22
+ this.status = status;
23
+ this.response = { status, data };
24
+ }
25
+ }
26
+
27
+ export interface AliaApiResult<T> {
28
+ status: number;
29
+ data: T;
30
+ }
31
+
32
+ export interface AliaApiRequest {
33
+ params?: Record<string, string | number | undefined>;
34
+ body?: unknown;
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ /**
39
+ * Alia's REST API for the signed-in person. Authentication, the preflight
40
+ * refresh and the single 401 replay belong to the linked Oxy client; this
41
+ * layer never reads or writes a bearer itself.
42
+ */
43
+ export interface AliaApi {
44
+ get<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
45
+ post<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
46
+ patch<T>(path: string, body?: unknown, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
47
+ delete<T>(path: string, request?: AliaApiRequest): Promise<AliaApiResult<T>>;
48
+ }
49
+
50
+ function withParams(path: string, params: AliaApiRequest['params']): string {
51
+ if (!params) return path;
52
+ const search = new URLSearchParams();
53
+ for (const [key, value] of Object.entries(params)) {
54
+ if (value !== undefined) search.append(key, String(value));
55
+ }
56
+ const query = search.toString();
57
+ return query === '' ? path : `${path}${path.includes('?') ? '&' : '?'}${query}`;
58
+ }
59
+
60
+ async function readBody(response: Response): Promise<unknown> {
61
+ const text = await response.text().catch(() => '');
62
+ if (text === '') return undefined;
63
+ try {
64
+ return JSON.parse(text);
65
+ } catch {
66
+ return text;
67
+ }
68
+ }
69
+
70
+ /** The structural slice of `OxyServices` the API layer needs. */
71
+ export interface LinkableOxy {
72
+ createLinkedClient(config: { baseURL: string }): {
73
+ client: {
74
+ requestAuthenticatedResponse(config: {
75
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
76
+ url: string;
77
+ body?: string;
78
+ headers?: Record<string, string>;
79
+ signal?: AbortSignal;
80
+ fetch?: typeof fetch;
81
+ }): Promise<Response>;
82
+ };
83
+ dispose(): void;
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Build the API over an Oxy client for `apiUrl`. Exposed for tests and for non-React callers.
89
+ *
90
+ * @param appAssertionHeaders the host's `X-Oxy-App-Assertion` (`app-assertion.ts`), on every request
91
+ */
92
+ export function createAliaApi(
93
+ oxyServices: LinkableOxy,
94
+ apiUrl: string,
95
+ appAssertionHeaders: AppAssertionHeaders = NO_APP_ASSERTION,
96
+ ): AliaApi {
97
+ async function send<T>(
98
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
99
+ path: string,
100
+ request: AliaApiRequest = {},
101
+ ): Promise<AliaApiResult<T>> {
102
+ const assertion = await appAssertionHeaders();
103
+ const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
104
+ try {
105
+ const headers = {
106
+ ...assertion,
107
+ ...(request.body === undefined ? {} : { 'Content-Type': 'application/json' }),
108
+ };
109
+ const response = await linked.client.requestAuthenticatedResponse({
110
+ method,
111
+ url: withParams(path, request.params),
112
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
113
+ ...(Object.keys(headers).length === 0 ? {} : { headers }),
114
+ ...(request.signal ? { signal: request.signal } : {}),
115
+ });
116
+ const data = await readBody(response);
117
+ if (!response.ok) throw new AliaApiError(response.status, data);
118
+ return { status: response.status, data: data as T };
119
+ } finally {
120
+ linked.dispose();
121
+ }
122
+ }
123
+
124
+ return {
125
+ get: (path, request) => send('GET', path, request),
126
+ post: (path, body, request) => send('POST', path, { ...request, body: body ?? {} }),
127
+ patch: (path, body, request) => send('PATCH', path, { ...request, body: body ?? {} }),
128
+ delete: (path, request) => send('DELETE', path, request),
129
+ };
130
+ }
131
+
132
+ /** Alia's REST API for the nearest runtime's `apiUrl`, as the signed-in person. */
133
+ export function useAliaApi(): AliaApi {
134
+ const { oxyServices } = useOxy();
135
+ const { apiUrl } = useAliaRuntime();
136
+ const appAssertionHeaders = useAppAssertionHeaders();
137
+ return useMemo(
138
+ () => createAliaApi(oxyServices as LinkableOxy, apiUrl, appAssertionHeaders),
139
+ [oxyServices, apiUrl, appAssertionHeaders],
140
+ );
141
+ }
@@ -0,0 +1,53 @@
1
+ // `@oxy.so/services/ui`, not the root barrel (which ships the sign-in panels, `check:entries`):
2
+ // `ui/client` does not export `useAppAssertion`.
3
+ import { useAppAssertion } from '@oxy.so/services/ui';
4
+ import { useMemo } from 'react';
5
+
6
+ /**
7
+ * Which Oxy app this conversation is in, for Alia (Alia ADR 0021, Oxy ADR 0045).
8
+ *
9
+ * An Oxy app may confine its own agent to itself — Clarity's assistant opens
10
+ * only inside Clarity — and the official apps share one session that names no
11
+ * app, so Alia reads the app from the Oxy-signed assertion the host's
12
+ * `OxyProvider` obtains and keeps current. Every request the SDK makes to Alia
13
+ * carries it as `X-Oxy-App-Assertion` when there is one; without one (an app
14
+ * Oxy cannot recognise yet, a signed-out person) nothing is sent, and only a
15
+ * confined agent is affected.
16
+ *
17
+ * The SDK never builds, stores or inspects the assertion: it is the host's
18
+ * provider's, read at send time so it is never stale.
19
+ */
20
+ export type AppAssertionHeaders = () => Promise<Record<string, string>>;
21
+
22
+ /** No app assertion: what a caller outside a React tree, or a test, passes. */
23
+ export const NO_APP_ASSERTION: AppAssertionHeaders = async () => ({});
24
+
25
+ /** The host's app assertion as request headers, `{}` when it has none or cannot get one. */
26
+ export function useAppAssertionHeaders(): AppAssertionHeaders {
27
+ const { getAppAssertionHeaders } = useAppAssertion();
28
+ return useMemo(
29
+ () => async () => {
30
+ try {
31
+ return await getAppAssertionHeaders();
32
+ } catch {
33
+ return {};
34
+ }
35
+ },
36
+ [getAppAssertionHeaders],
37
+ );
38
+ }
39
+
40
+ /** The host's app assertion itself, for the voice socket, which cannot carry a header. */
41
+ export function useAppAssertionToken(): () => Promise<string | null> {
42
+ const { getAppAssertion } = useAppAssertion();
43
+ return useMemo(
44
+ () => async () => {
45
+ try {
46
+ return await getAppAssertion();
47
+ } catch {
48
+ return null;
49
+ }
50
+ },
51
+ [getAppAssertion],
52
+ );
53
+ }
@@ -0,0 +1,96 @@
1
+ import type { AliaAttachment } from '../types';
2
+
3
+ export type MessageContentPart =
4
+ | { type: 'text'; text: string }
5
+ | { type: 'image_url'; image_url: { url: string } }
6
+ | { type: 'file'; file: { file_id: string; filename: string; mime: string } };
7
+
8
+ export interface BuiltMessageContent {
9
+ content: string | MessageContentPart[];
10
+ /**
11
+ * Attachments the request does NOT carry, so the caller can say so.
12
+ *
13
+ * Never empty silently: everything here was visibly attached in the composer,
14
+ * and a file that appears attached and then is not sent is the failure #608
15
+ * §1.6 is about — a visible state that does not correspond to a real
16
+ * capability.
17
+ */
18
+ dropped: DroppedAttachment[];
19
+ }
20
+
21
+ export interface DroppedAttachment {
22
+ name: string;
23
+ /**
24
+ * `unsupported` — the chat has no way to send a document (no upload was given).
25
+ * `unreadable` — an image whose bytes could not be read off the device, or a
26
+ * document that could not be uploaded.
27
+ */
28
+ reason: 'unsupported' | 'unreadable';
29
+ }
30
+
31
+ /** Puts a document in the person's Oxy Files and answers its id. */
32
+ export type UploadDocument = (attachment: AliaAttachment) => Promise<string>;
33
+
34
+ /**
35
+ * The request body for a turn that carries attachments, and an account of what
36
+ * it could not carry.
37
+ *
38
+ * A photo travels inline, as an `image_url` part with its `data:` URL. A
39
+ * document travels as a `file` part naming an Oxy Files id
40
+ * (`docs/agent-embedding.md` §5): it is uploaded to the person's Files first,
41
+ * with `upload`, and a chat given no `upload` cannot send documents and says so.
42
+ */
43
+ export async function buildMessageContent(
44
+ text: string,
45
+ attachments: AliaAttachment[],
46
+ upload?: UploadDocument,
47
+ ): Promise<BuiltMessageContent> {
48
+ const dropped: DroppedAttachment[] = [];
49
+
50
+ const usable = attachments.filter((att) => {
51
+ if (!att.uri) return false;
52
+ if (att.type !== 'image' && upload === undefined) {
53
+ dropped.push({ name: att.name, reason: 'unsupported' });
54
+ return false;
55
+ }
56
+ return true;
57
+ });
58
+
59
+ if (usable.length === 0) return { content: text, dropped };
60
+
61
+ const parts: MessageContentPart[] = [{ type: 'text', text }];
62
+
63
+ // The readers need the platform's file access, loaded on the first attachment: a turn of plain
64
+ // text (and everything that imports this module) never touches it.
65
+ const { imageUriToDataUrl } = await import('../lib/image-data-url');
66
+ for (const att of usable) {
67
+ if (att.type !== 'image' && upload !== undefined) {
68
+ try {
69
+ const fileId = await upload(att);
70
+ parts.push({
71
+ type: 'file',
72
+ file: { file_id: fileId, filename: att.name, mime: att.mimeType },
73
+ });
74
+ } catch {
75
+ dropped.push({ name: att.name, reason: 'unreadable' });
76
+ }
77
+ continue;
78
+ }
79
+ try {
80
+ parts.push({
81
+ type: 'image_url',
82
+ image_url: { url: await imageUriToDataUrl(att.uri, att.mimeType) },
83
+ });
84
+ } catch {
85
+ // Reported rather than skipped. A picture that did not arrive is worth
86
+ // a sentence; it used to be a `continue`.
87
+ dropped.push({ name: att.name, reason: 'unreadable' });
88
+ }
89
+ }
90
+
91
+ // Every attachment failed, so the parts array is a text part and nothing
92
+ // else — which is just the text, said in a more expensive way.
93
+ if (parts.length === 1) return { content: text, dropped };
94
+
95
+ return { content: parts, dropped };
96
+ }