@alia.onl/sdk 20.2.0 → 21.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 21.2.0
4
+
5
+ ### Added
6
+
7
+ - **`parseCatalogueTool` and `toolRendererKey` from `@alia.onl/sdk/tools`.** Which app a tool call
8
+ belongs to (`oxy_<app>__<tool>` / `self_<app>__<tool>`) is now readable without loading React
9
+ Native, so a host's own pure models and tests — the code that decides what a screen shows —
10
+ can tell its catalogue's results apart. The main entry exports the same two functions as before.
11
+
12
+ ## 21.1.0
13
+
14
+ A shared conversation has one address, Alia's, whichever app minted it (`docs/agent-embedding.md`
15
+ §6).
16
+
17
+ ### Added
18
+
19
+ - **`url` on `AliaConversationShare`**: `useShareConversation().share` (and a host's `onShare`)
20
+ gets the link to hand on, `https://alia.onl/shared/<token>`, rather than a token to build its own
21
+ page around.
22
+ - **`aliaSharedConversationUrl(token)`**: the same link, for an app that redirects the share links
23
+ it minted before to Alia's page.
24
+
25
+ ## 21.0.0
26
+
27
+ A conversation records the Oxy app it started in, so an embedding app lists only its own while
28
+ the Alia app lists all of them (`docs/agent-embedding.md` §6). The app is named once, on the chat.
29
+
30
+ ### Added
31
+
32
+ - **`app` on `AliaChatContent`, `AliaChatSheet`, `AliaChatScreen`, `AliaAskProvider` and
33
+ `AliaRuntimeProvider`**: the embedding app's slug (`homiio`, `mercaria`). Every turn carries it
34
+ as `host.app` — with an empty tool list when the chat has no host tools — and
35
+ `POST /v1/conversations/new` and `POST /v1/conversations` carry it as `app`, so a conversation
36
+ the chat starts is labelled with it. Omitted, the chat is Alia's own and labels nothing.
37
+ - **`useConversations({ app })`**: only the conversations started in that app
38
+ (`GET /v1/conversations?app=`), composable with `agentId`. The chat's `app` does not imply it:
39
+ the app that wants only its own asks for them.
40
+ - **`Conversation.app`**: the app a conversation started in, or `null`.
41
+ - **The sheet's recent conversations are the app's.** In a sheet with an `app`, the recents under
42
+ the greeting (20.1.0) are the ones started in that app; Alia's own sheet still lists any.
43
+
44
+ ### Changed (breaking)
45
+
46
+ - **`AliaHostTools` has no `app`.** Host tools are the chat's `app`'s, which a chat with
47
+ `hostTools` must name; one without throws on render. Move `hostTools.app` to the chat's `app`.
48
+ - **`hostRequest(app, tools, outcomes)`** and **`HostOutcomeQueue.dispatch(action, tools, app)`**
49
+ take the app.
50
+ - **`aliaQueryKeys.conversations.withAgent(id)` is `filtered({ agentId, app })`.**
51
+
52
+ ## 20.3.0
53
+
54
+ A host hears when a turn has stopped streaming.
55
+
56
+ ### Added
57
+
58
+ - **`AliaRuntimeHost.onTurnEnd`**, the pair of `onTurnStart`: called exactly once for each turn
59
+ the runtime sends, once it stops streaming for any reason — finished, stopped (Stop) or failed —
60
+ and only while the conversation that sent it is still mounted, so a surface that left mid-turn
61
+ is never called back. A host that holds back what would take the chat off the screen runs it
62
+ here, instead of watching `useConversationStore().streamingConversationId`.
63
+
3
64
  ## 20.2.0
4
65
 
5
66
  An app mounts its one sheet at the root without the chat in its entry bundle.
package/README.md CHANGED
@@ -341,6 +341,9 @@ const host: AliaRuntimeHost = {
341
341
  showThought: (opening) => (opening === 'automatic' && panel.isOpen ? false : panel.show('thought')),
342
342
  openAutomation: (id) => router.push(`/automations/${id}`), // a scheduled-task card is a link
343
343
  faviconUrl: (url) => favicons.of(url), // the work log's source icons
344
+ // the edges of a turn this runtime sends
345
+ onTurnStart: () => executor.beginTurn(),
346
+ onTurnEnd: () => executor.runHeldBack(), // finished, stopped or failed: once per turn
344
347
  };
345
348
 
346
349
  <AliaRuntimeProvider
@@ -354,6 +357,12 @@ const host: AliaRuntimeHost = {
354
357
  </AliaRuntimeProvider>;
355
358
  ```
356
359
 
360
+ `onTurnEnd` is called exactly once for each turn the runtime sends, when it stops streaming —
361
+ the answer was written, the person pressed Stop, or the turn failed — and only while the
362
+ conversation that sent it is still mounted. A host that holds back what would take the chat off
363
+ the screen (a host tool that navigates away) runs it there; a surface that left mid-turn is never
364
+ called back, so what it held back goes with it.
365
+
357
366
  The sheet and the Alia app differ ONLY in what they put there:
358
367
 
359
368
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "20.2.0",
3
+ "version": "21.2.0",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -40,6 +40,7 @@ export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
40
40
  export interface AliaAskProviderProps
41
41
  extends Pick<
42
42
  AliaChatSheetProps,
43
+ | 'app'
43
44
  | 'hostTools'
44
45
  | 'toolRenderers'
45
46
  | 'labels'
@@ -50,7 +50,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
50
50
  import { type DeviceModel, buildPowerLevelOptions } from '../lib/power-level-options';
51
51
  import { ALIA_FEATURES, featureOffer, useAgentFeatures, useAgentProfile } from '../runtime/agents';
52
52
  import { type AliaRuntimeHost, AliaRuntimeProvider, useAliaRuntime } from '../runtime/config';
53
- import type { AliaHostTools } from '../runtime/host-tools';
53
+ import { type AliaHostTools, APP_SLUG } from '../runtime/host-tools';
54
54
  import { useCreateConversation } from '../runtime/conversations';
55
55
  import { aliaQueryKeys } from '../runtime/query-keys';
56
56
  import type { Message } from '../runtime/types';
@@ -137,12 +137,20 @@ export interface AliaChatContentProps {
137
137
  */
138
138
  ephemeral?: boolean;
139
139
  apiUrl?: string;
140
+ /**
141
+ * The Oxy app this chat is embedded in, by its slug (`homiio`, `mercaria`). A conversation the
142
+ * chat starts is labelled with it, so the app can list only its own
143
+ * (`useConversations({ app })`), and its `hostTools` are this app's. Omitted, the chat is
144
+ * Alia's own (`docs/agent-embedding.md` §6).
145
+ */
146
+ app?: string;
140
147
  /** App context injected into the system prompt. */
141
148
  clientContext?: string;
142
149
  /**
143
150
  * The UI effects this app's client performs for the lead (`docs/agent-embedding.md` §2). Declared
144
- * with every turn; an action the stream asks for is performed once, by `execute`, and its
145
- * outcome reaches the model with the next turn.
151
+ * with every turn under the chat's `app`, which a chat with host tools must name; an action the
152
+ * stream asks for is performed once, by `execute`, and its outcome reaches the model with the
153
+ * next turn.
146
154
  */
147
155
  hostTools?: AliaHostTools;
148
156
  /** The app's own drawing of a catalogue tool's result, keyed `<app>:<tool>`, before the generic card. */
@@ -715,12 +723,24 @@ export function AliaChatContent({
715
723
  onConversationChange,
716
724
  runtime,
717
725
  apiUrl,
726
+ app,
718
727
  toolRenderers,
719
728
  onFeatureLocked,
720
729
  ...props
721
730
  }: AliaChatContentProps) {
722
731
  const [ownId, setOwnId] = useState<string | undefined>(undefined);
723
732
  const parent = useAliaRuntime();
733
+ const chatApp = app ?? parent.app;
734
+ if (chatApp !== undefined && !APP_SLUG.test(chatApp)) {
735
+ throw new Error(
736
+ `AliaChatContent: app "${chatApp}" is not an app slug (^[a-z][a-z0-9-]{0,31}$)`,
737
+ );
738
+ }
739
+ if (props.hostTools !== undefined && chatApp === undefined) {
740
+ throw new Error(
741
+ "AliaChatContent: hostTools are an app's, so a chat with hostTools names its app",
742
+ );
743
+ }
724
744
  // Who decides which conversation is open: a host that navigates (a route) does; with none, the
725
745
  // chat does (the sheet). Either way the host hears of it first, then `onConversationChange`.
726
746
  const hostOpens = runtime?.openConversation ?? parent.host.openConversation;
@@ -746,7 +766,7 @@ export function AliaChatContent({
746
766
 
747
767
  return (
748
768
  <AliaChatLabelsProvider labels={labels}>
749
- <AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host}>
769
+ <AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host} app={chatApp}>
750
770
  <AliaToolRenderersProvider value={toolRenderers ?? NO_RENDERERS}>
751
771
  <AliaConversationView {...props} conversationId={controlledId ?? ownId} />
752
772
  </AliaToolRenderersProvider>
@@ -42,6 +42,8 @@ export interface AliaChatScreenProps {
42
42
  agent?: AliaChatAgentRef;
43
43
  /** App context injected into system prompt */
44
44
  clientContext?: string;
45
+ /** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
46
+ app?: string;
45
47
  /** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
46
48
  hostTools?: AliaHostTools;
47
49
  /** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
@@ -87,6 +89,7 @@ export interface AliaChatScreenProps {
87
89
 
88
90
  export function AliaChatScreen({
89
91
  agent,
92
+ app,
90
93
  clientContext,
91
94
  hostTools,
92
95
  toolRenderers,
@@ -147,6 +150,7 @@ export function AliaChatScreen({
147
150
  <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
148
151
  <AliaChatContent
149
152
  agent={agent}
153
+ app={app}
150
154
  clientContext={clientContext}
151
155
  hostTools={hostTools}
152
156
  toolRenderers={toolRenderers}
@@ -54,6 +54,8 @@ export interface AliaChatSheetProps {
54
54
  agent?: AliaChatAgentRef;
55
55
  /** App context injected into system prompt */
56
56
  clientContext?: string;
57
+ /** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
58
+ app?: string;
57
59
  /** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
58
60
  hostTools?: AliaHostTools;
59
61
  /** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
@@ -110,6 +112,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
110
112
  (
111
113
  {
112
114
  agent,
115
+ app,
113
116
  clientContext,
114
117
  hostTools,
115
118
  toolRenderers,
@@ -268,6 +271,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
268
271
  flights,
269
272
  layout: 'sheet' as const,
270
273
  agent,
274
+ app,
271
275
  clientContext,
272
276
  hostTools,
273
277
  toolRenderers,
@@ -3,6 +3,7 @@ import { Text } from '@oxy.so/bloom/typography';
3
3
  import { useMemo } from 'react';
4
4
  import { View } from 'react-native';
5
5
  import { useLabels } from '../lib/labels';
6
+ import { useAliaRuntime } from '../runtime/config';
6
7
  import { useConversations } from '../runtime/conversations';
7
8
 
8
9
  /** How many of the lead's conversations the sheet offers to pick up again. */
@@ -11,7 +12,9 @@ export const RECENT_CONVERSATIONS_SHOWN = 3;
11
12
  /**
12
13
  * The person's latest conversations with the sheet's lead, under its greeting: "Ask Sindi" picks
13
14
  * up where it left off instead of every opening being a blank page. Alia's own sheet lists the
14
- * person's latest conversations of any kind. Nothing while loading or when there are none.
15
+ * person's latest conversations of any kind. In an app's sheet (the chat's `app`) they are the ones
16
+ * started in that app: Mercaria's sheet offers Mercaria's, never a Homiio chat. Nothing while
17
+ * loading or when there are none.
15
18
  */
16
19
  export function AliaRecentConversations({
17
20
  agentId,
@@ -22,7 +25,11 @@ export function AliaRecentConversations({
22
25
  onOpen: (conversationId: string) => void;
23
26
  }) {
24
27
  const labels = useLabels();
25
- const { data } = useConversations(agentId === undefined ? {} : { agentId });
28
+ const { app } = useAliaRuntime();
29
+ const { data } = useConversations({
30
+ ...(agentId === undefined ? {} : { agentId }),
31
+ ...(app === undefined ? {} : { app }),
32
+ });
26
33
  const recent = useMemo(
27
34
  () => data?.pages[0]?.conversations.slice(0, RECENT_CONVERSATIONS_SHOWN) ?? [],
28
35
  [data],
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export type { AliaParticipantsHeaderProps } from './components/AliaParticipantsH
17
17
  export { ALIA_LEAD, leadOf } from './lib/participants';
18
18
  export type { AliaChatParticipants, AliaLead, AliaParticipant } from './lib/participants';
19
19
  export { ANCHORS, leadToProfile } from './lib/flights';
20
- export { parseCatalogueTool, toolRendererKey } from './lib/tool-renderers';
20
+ export { parseCatalogueTool, toolRendererKey } from './lib/catalogue-tool';
21
21
  export type { AliaToolRenderers, AliaToolResult } from './lib/tool-renderers';
22
22
  export { AliaAskProvider, useAskAgent } from './components/AliaAskProvider';
23
23
  export type { AliaAsk, AliaAskAgents, AliaAskProviderProps } from './components/AliaAskProvider';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The canonical name of an app's catalogue tool — `oxy_<app>__<tool>` for the person's
3
+ * account, `self_<app>__<tool>` for the agent's own — read back into its app and tool.
4
+ *
5
+ * Pure (no React, no React Native), so a host's own models and tests can tell which
6
+ * app a tool call belongs to: it is the `@alia.onl/sdk/tools` entry's, and the renderers
7
+ * (`tool-renderers.tsx`) key on it. The app part is an Oxy app slug; the tool part is
8
+ * whatever follows the separator. Nothing else names a renderer — never the model's text.
9
+ */
10
+ const CANONICAL_TOOL = /^(?:oxy|self)_([a-z][a-z0-9-]{0,63})__([A-Za-z0-9_-]+)$/;
11
+
12
+ /** The app and tool of a canonical catalogue tool name, or `null` for any other name. */
13
+ export function parseCatalogueTool(toolName: string): { app: string; tool: string } | null {
14
+ const match = CANONICAL_TOOL.exec(toolName);
15
+ return match ? { app: match[1], tool: match[2] } : null;
16
+ }
17
+
18
+ /** The renderer key of a tool name (`<app>:<tool>`), or `null` when it is not a catalogue tool. */
19
+ export function toolRendererKey(toolName: string): string | null {
20
+ const parsed = parseCatalogueTool(toolName);
21
+ return parsed ? `${parsed.app}:${parsed.tool}` : null;
22
+ }
@@ -13,3 +13,12 @@ export const ALIA_API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api
13
13
  * with CORS open (`packages/app/worker/index.js`).
14
14
  */
15
15
  export const ALIA_WEB_ORIGIN = 'https://alia.onl';
16
+
17
+ /**
18
+ * The one public page of a shared conversation, on Alia's web app, for every agent and every app
19
+ * that embeds one (`docs/agent-embedding.md` §6). An app shares this URL rather than a page of its
20
+ * own, so a link reads the same wherever it was minted.
21
+ */
22
+ export function aliaSharedConversationUrl(token: string): string {
23
+ return `${ALIA_WEB_ORIGIN}/shared/${encodeURIComponent(token)}`;
24
+ }
@@ -22,23 +22,7 @@ export interface AliaToolResult {
22
22
  */
23
23
  export type AliaToolRenderers = Readonly<Record<string, (result: AliaToolResult) => ReactNode>>;
24
24
 
25
- /**
26
- * `oxy_<app>__<tool>` or `self_<app>__<tool>`: the only names a renderer is keyed from. The app
27
- * part is an Oxy app slug; the tool part is whatever follows the separator.
28
- */
29
- const CANONICAL_TOOL = /^(?:oxy|self)_([a-z][a-z0-9-]{0,63})__([A-Za-z0-9_-]+)$/;
30
-
31
- /** The app and tool of a canonical catalogue tool name, or `null` for any other name. */
32
- export function parseCatalogueTool(toolName: string): { app: string; tool: string } | null {
33
- const match = CANONICAL_TOOL.exec(toolName);
34
- return match ? { app: match[1], tool: match[2] } : null;
35
- }
36
-
37
- /** The renderer key of a tool name (`<app>:<tool>`), or `null` when it is not a catalogue tool. */
38
- export function toolRendererKey(toolName: string): string | null {
39
- const parsed = parseCatalogueTool(toolName);
40
- return parsed ? `${parsed.app}:${parsed.tool}` : null;
41
- }
25
+ export { parseCatalogueTool, toolRendererKey } from './catalogue-tool';
42
26
 
43
27
  const RenderersContext = createContext<AliaToolRenderers>({});
44
28
 
@@ -72,6 +72,12 @@ export interface AliaRuntimeHost {
72
72
  turnRequest?: () => AliaTurnRequest;
73
73
  /** A turn is about to be sent from the composer or a retry. */
74
74
  onTurnStart?: () => void;
75
+ /**
76
+ * A turn this runtime sent has stopped streaming: it finished, was stopped, or failed. Called
77
+ * exactly once per turn, after the stream has settled, and only while the conversation that
78
+ * sent it is still mounted: a surface that left mid-turn is never called back for it.
79
+ */
80
+ onTurnEnd?: () => void;
75
81
  /** Real answer text just arrived (throttled): a haptic tick. */
76
82
  onOutput?: () => void;
77
83
  /** The stream's usage frame: credits left and the spending warning. */
@@ -134,6 +140,14 @@ export interface AliaRuntimeConfig {
134
140
  /** How read-aloud, dictation and calls speak; omitted, the server's and the platform's defaults. */
135
141
  speech: SpeechPreferences;
136
142
  host: AliaRuntimeHost;
143
+ /**
144
+ * The Oxy app the chat is embedded in, by its slug (`^[a-z][a-z0-9-]{0,31}$`): `homiio`,
145
+ * `mercaria`. A conversation it starts is labelled with it, and its host tools are this app's.
146
+ * Omitted, the chat is Alia's own and its conversations carry no app (`docs/agent-embedding.md`
147
+ * §6). A label for listing, never authority: which app a request comes from is proven by the
148
+ * app assertion, not by this.
149
+ */
150
+ app: string | undefined;
137
151
  }
138
152
 
139
153
  export interface AliaRuntimeProviderProps extends Partial<Omit<AliaRuntimeConfig, 'host'>> {
@@ -147,6 +161,7 @@ const DEFAULT_CONFIG: AliaRuntimeConfig = {
147
161
  streamFetch: undefined,
148
162
  speech: {},
149
163
  host: {},
164
+ app: undefined,
150
165
  };
151
166
 
152
167
  const AliaRuntimeContext = createContext<AliaRuntimeConfig>(DEFAULT_CONFIG);
@@ -164,6 +179,7 @@ export function AliaRuntimeProvider({
164
179
  streamFetch,
165
180
  speech,
166
181
  host,
182
+ app,
167
183
  children,
168
184
  }: AliaRuntimeProviderProps) {
169
185
  const parent = useContext(AliaRuntimeContext);
@@ -175,8 +191,9 @@ export function AliaRuntimeProvider({
175
191
  streamFetch: streamFetch ?? parent.streamFetch,
176
192
  speech: speech ?? parent.speech,
177
193
  host: host === undefined ? parent.host : { ...parent.host, ...host },
194
+ app: app ?? parent.app,
178
195
  };
179
- }, [apiUrl, offline, streamFetch, speech, host, parent]);
196
+ }, [apiUrl, offline, streamFetch, speech, host, app, parent]);
180
197
  return <AliaRuntimeContext.Provider value={value}>{children}</AliaRuntimeContext.Provider>;
181
198
  }
182
199
 
@@ -13,7 +13,7 @@ import { type AliaApi, useAliaApi } from './api';
13
13
  import { type OfflineConversationStore, useAliaRuntime } from './config';
14
14
  import { errorMessage as getErrorMessage, errorStatus } from './error-utils';
15
15
  import { normalizeConversationMessages } from './message-history';
16
- import { aliaQueryKeys } from './query-keys';
16
+ import { aliaQueryKeys, type ConversationsFilter } from './query-keys';
17
17
  import { API_ROUTES } from './routes';
18
18
  import { createRandomUuid } from './random-uuid';
19
19
  import type { Conversation, ConversationsInfiniteData, ConversationsPage, Message } from './types';
@@ -96,23 +96,25 @@ export interface ConversationsClient {
96
96
  offline: OfflineConversations;
97
97
  /** Read at call time: the title of a chat created while signed out. */
98
98
  newChatTitle: string;
99
+ /** The chat's app, which labels a conversation it creates (`AliaRuntimeConfig.app`). */
100
+ app: string | undefined;
99
101
  }
100
102
 
101
103
  /** The runtime's conversation client for the nearest provider. */
102
104
  export function useConversationsClient(): ConversationsClient {
103
105
  const api = useAliaApi();
104
- const { offline } = useAliaRuntime();
106
+ const { offline, app } = useAliaRuntime();
105
107
  const { newChat } = useLabels();
106
108
  return useMemo(
107
- () => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat }),
108
- [api, offline, newChat],
109
+ () => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat, app }),
110
+ [api, offline, newChat, app],
109
111
  );
110
112
  }
111
113
 
112
114
  async function fetchConversationsPage(
113
115
  client: ConversationsClient,
114
116
  pageParam: string | undefined,
115
- agentId: string | undefined,
117
+ { agentId, app }: ConversationsFilter,
116
118
  ): Promise<ConversationsPage> {
117
119
  try {
118
120
  const { data } = await client.api.get<{
@@ -124,6 +126,7 @@ async function fetchConversationsPage(
124
126
  limit: PAGE_SIZE,
125
127
  ...(pageParam ? { cursor: pageParam } : {}),
126
128
  ...(agentId === undefined ? {} : { agentId }),
129
+ ...(app === undefined ? {} : { app }),
127
130
  },
128
131
  });
129
132
  return {
@@ -137,9 +140,9 @@ async function fetchConversationsPage(
137
140
  hasMore: data.hasMore,
138
141
  };
139
142
  } catch (error: unknown) {
140
- // If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's are
141
- // never kept offline).
142
- if (errorStatus(error) === 401 && agentId === undefined) {
143
+ // If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's and an
144
+ // app's are never kept offline).
145
+ if (errorStatus(error) === 401 && agentId === undefined && app === undefined) {
143
146
  const conversations = (await client.offline.read()).map((c) => ({
144
147
  ...c,
145
148
  messages: [] as Message[],
@@ -158,19 +161,21 @@ async function fetchConversationsPage(
158
161
 
159
162
  /**
160
163
  * The person's conversations, newest first, a page at a time. The sidebar and the sheet read this.
161
- * Given `agentId`, only the conversations with that agent (`GET /v1/conversations?agentId=`).
164
+ * Given `agentId`, only the conversations with that agent; given `app`, only the ones started in
165
+ * that app — an embedding app's own list (`GET /v1/conversations?agentId=&app=`). Neither is
166
+ * implied by the chat's `app`: the app that wants only its own says so here.
162
167
  */
163
- export function useConversations({ agentId }: { agentId?: string } = {}) {
168
+ export function useConversations({ agentId, app }: ConversationsFilter = {}) {
164
169
  const { isAuthenticated } = useOxy();
165
170
  const client = useConversationsClient();
166
171
 
167
172
  return useInfiniteQuery({
168
173
  queryKey:
169
- agentId === undefined
174
+ agentId === undefined && app === undefined
170
175
  ? aliaQueryKeys.conversations.all
171
- : aliaQueryKeys.conversations.withAgent(agentId),
176
+ : aliaQueryKeys.conversations.filtered({ agentId, app }),
172
177
  queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
173
- fetchConversationsPage(client, pageParam, agentId),
178
+ fetchConversationsPage(client, pageParam, { agentId, app }),
174
179
  initialPageParam: undefined as string | undefined,
175
180
  getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
176
181
  staleTime: FIVE_MINUTES,
@@ -309,12 +314,14 @@ export function useSaveConversation() {
309
314
  conversationId: id,
310
315
  messages,
311
316
  ...(title && { title }),
317
+ ...(client.app === undefined ? {} : { app: client.app }),
312
318
  });
313
319
  return {
314
320
  id: data.id,
315
321
  title: data.title,
316
322
  lastMessage: data.lastMessage,
317
323
  source: data.source,
324
+ app: data.app,
318
325
  createdAt: new Date(data.createdAt),
319
326
  updatedAt: new Date(data.updatedAt),
320
327
  messages,
@@ -515,6 +522,7 @@ export function useCreateConversation() {
515
522
  try {
516
523
  const { data } = await client.api.post<ApiConversation>(API_ROUTES.conversations.create, {
517
524
  ...(params?.agentId && { agentId: params.agentId }),
525
+ ...(client.app === undefined ? {} : { app: client.app }),
518
526
  });
519
527
  return {
520
528
  id: data.id,
@@ -522,6 +530,7 @@ export function useCreateConversation() {
522
530
  lastMessage: undefined,
523
531
  source: data.source,
524
532
  agentId: data.agentId,
533
+ app: data.app,
525
534
  createdAt: new Date(data.createdAt),
526
535
  updatedAt: new Date(data.updatedAt),
527
536
  messages: [],
@@ -35,10 +35,12 @@ export interface AliaHostOutcome {
35
35
  detail?: string;
36
36
  }
37
37
 
38
- /** The host tools a surface declares (`hostTools` on the chat). */
38
+ /**
39
+ * The host tools a surface declares (`hostTools` on the chat). They are the tools of the chat's
40
+ * `app`, which a surface with host tools must name: the app is said once, on the chat, and is what
41
+ * the model sees them under (`host_<app>__<name>`).
42
+ */
39
43
  export interface AliaHostTools {
40
- /** The app's slug, `^[a-z][a-z0-9-]{0,31}$`. */
41
- app: string;
42
44
  /**
43
45
  * At most 16. What the host's screen shows now is not part of the declaration: it is the chat's
44
46
  * `clientContext`, built by the host per turn.
@@ -80,14 +82,18 @@ export class HostOutcomeQueue {
80
82
  private ready: HostOutcomeRecord[] = [];
81
83
 
82
84
  /**
83
- * Perform `action` with `tools`, once. Resolves when its outcome is queued; `false` when the
84
- * action had already been performed and nothing was done.
85
+ * Perform `action` with the tools `app` declared, once. Resolves when its outcome is queued;
86
+ * `false` when the action had already been performed and nothing was done.
85
87
  */
86
- async dispatch(action: HostAction, tools: AliaHostTools | undefined): Promise<boolean> {
88
+ async dispatch(
89
+ action: HostAction,
90
+ tools: AliaHostTools | undefined,
91
+ app: string | undefined,
92
+ ): Promise<boolean> {
87
93
  if (this.performed.has(action.toolCallId)) return false;
88
94
  this.performed.add(action.toolCallId);
89
95
  let outcome: AliaHostOutcome;
90
- if (tools === undefined || action.app !== tools.app) {
96
+ if (tools === undefined || app === undefined || action.app !== app) {
91
97
  outcome = { outcome: 'rejected', detail: 'This app did not declare that tool.' };
92
98
  } else if (!tools.definitions.some((tool) => tool.name === action.name)) {
93
99
  outcome = { outcome: 'rejected', detail: `Unknown tool "${action.name}".` };
@@ -121,16 +127,23 @@ export class HostOutcomeQueue {
121
127
  }
122
128
  }
123
129
 
124
- /** The `host` member of a request, or nothing for a surface without host tools. */
130
+ /**
131
+ * The `host` member of a request: the chat's `app` and its host tools (none is an empty list),
132
+ * or nothing for a chat that names no app — Alia's own.
133
+ *
134
+ * The app is also what labels a conversation this turn starts (`docs/agent-embedding.md` §6), so
135
+ * a surface without host tools still sends it.
136
+ */
125
137
  export function hostRequest(
138
+ app: string | undefined,
126
139
  tools: AliaHostTools | undefined,
127
140
  outcomes: readonly HostOutcomeRecord[],
128
141
  ): HostRequest | undefined {
129
- if (tools === undefined) return undefined;
142
+ if (app === undefined) return undefined;
130
143
  return {
131
- app: tools.app,
144
+ app,
132
145
  version: 1,
133
- tools: tools.definitions.map(({ name, description, inputSchema }) => ({
146
+ tools: (tools?.definitions ?? []).map(({ name, description, inputSchema }) => ({
134
147
  name,
135
148
  description,
136
149
  inputSchema,
@@ -138,3 +151,6 @@ export function hostRequest(
138
151
  ...(outcomes.length === 0 ? {} : { outcomes: [...outcomes] }),
139
152
  };
140
153
  }
154
+
155
+ /** The slug an app names itself by, here and on the server: `^[a-z][a-z0-9-]{0,31}$`. */
156
+ export const APP_SLUG = /^[a-z][a-z0-9-]{0,31}$/;
@@ -51,6 +51,7 @@ export {
51
51
  useShareConversation,
52
52
  useSharedConversation,
53
53
  } from './sharing';
54
+ export { aliaSharedConversationUrl } from '../lib/origins';
54
55
  export type {
55
56
  AliaConversationShare,
56
57
  AliaSharedAgent,
@@ -60,7 +61,7 @@ export type {
60
61
  export { uploadToOxyFiles, useDocumentUpload } from './oxy-files';
61
62
  export type { OxyFilesClient } from './oxy-files';
62
63
  export type { AliaApi, AliaApiRequest, AliaApiResult } from './api';
63
- export { aliaQueryKeys } from './query-keys';
64
+ export { aliaQueryKeys, type ConversationsFilter } from './query-keys';
64
65
  export {
65
66
  ConversationNotFoundError,
66
67
  useClearConversation,
@@ -2,12 +2,24 @@
2
2
  * The React Query keys of the conversation runtime. The host's persister and its sidebar read
3
3
  * the same keys the hooks write, so a list shown anywhere is the list the chat updates.
4
4
  */
5
+ /** What a conversation list may be narrowed to (`GET /v1/conversations?agentId=&app=`). */
6
+ export interface ConversationsFilter {
7
+ /** Only the conversations with this agent. */
8
+ agentId?: string;
9
+ /** Only the conversations started in this app, by its slug (`docs/agent-embedding.md` §6). */
10
+ app?: string;
11
+ }
12
+
5
13
  export const aliaQueryKeys = {
6
14
  conversations: {
7
15
  all: ['conversations'] as const,
8
16
  detail: (id: string) => ['conversation', id] as const,
9
- /** The person's conversations with one agent: under `all`, so every write to the list reaches it. */
10
- withAgent: (agentId: string) => ['conversations', { agentId }] as const,
17
+ /**
18
+ * The person's conversations narrowed to one agent, one app or both: under `all`, so every
19
+ * write to the list reaches it.
20
+ */
21
+ filtered: (filter: ConversationsFilter) =>
22
+ ['conversations', { agentId: filter.agentId, app: filter.app }] as const,
11
23
  },
12
24
  /** An agent the chat is opened on: its profile and the state of its feature gates. */
13
25
  agents: {
@@ -1,5 +1,6 @@
1
1
  import { useMutation, useQuery } from '@tanstack/react-query';
2
2
  import { useMemo } from 'react';
3
+ import { aliaSharedConversationUrl } from '../lib/origins';
3
4
  import { useAliaApi } from './api';
4
5
  import { useAliaRuntime } from './config';
5
6
  import { aliaQueryKeys } from './query-keys';
@@ -9,8 +10,10 @@ import type { Message } from './types';
9
10
  /** A conversation's read-only link (`POST /v1/conversations/:id/share`, `docs/agent-embedding.md` §6). */
10
11
  export interface AliaConversationShare {
11
12
  conversationId: string;
12
- /** What `GET /v1/shared/:token` reads it by; the host builds its own URL around it. */
13
+ /** What `GET /v1/shared/:token` reads it by. */
13
14
  token: string;
15
+ /** The link to hand on: Alia's one shared-conversation page (`aliaSharedConversationUrl`). */
16
+ url: string;
14
17
  expiresAt: string;
15
18
  }
16
19
 
@@ -36,7 +39,12 @@ export function useShareConversation() {
36
39
  if (typeof data?.token !== 'string' || typeof data.expiresAt !== 'string') {
37
40
  throw new Error('The share link came back without a token.');
38
41
  }
39
- return { conversationId, token: data.token, expiresAt: data.expiresAt };
42
+ return {
43
+ conversationId,
44
+ token: data.token,
45
+ url: aliaSharedConversationUrl(data.token),
46
+ expiresAt: data.expiresAt,
47
+ };
40
48
  },
41
49
  });
42
50
  const revoke = useMutation({
@@ -93,6 +93,8 @@ export interface Conversation {
93
93
  * arrives carrying an explicit `null`.
94
94
  */
95
95
  agentId?: string | null;
96
+ /** The app the conversation started in, by its slug, or `null` for Alia's own surfaces. */
97
+ app?: string | null;
96
98
  createdAt: Date;
97
99
  updatedAt: Date;
98
100
  messages: Message[];
@@ -153,10 +153,12 @@ export function useConversationStream({
153
153
  clientContext,
154
154
  hostTools,
155
155
  }: UseConversationStreamOptions = {}) {
156
- const { apiUrl, streamFetch, host } = useAliaRuntime();
156
+ const { apiUrl, streamFetch, host, app } = useAliaRuntime();
157
157
  /** Read at send time and when an action arrives: the host's declaration may change between turns. */
158
158
  const hostToolsRef = useRef(hostTools);
159
159
  hostToolsRef.current = hostTools;
160
+ const appRef = useRef(app);
161
+ appRef.current = app;
160
162
  /** This conversation's host actions: performed once, their outcomes held until a request carries them. */
161
163
  const hostOutcomesRef = useRef(new HostOutcomeQueue());
162
164
  const labels = useLabels();
@@ -260,9 +262,14 @@ export function useConversationStream({
260
262
  }, 50);
261
263
  }, [flushPendingUpdates]);
262
264
 
263
- // Cleanup: flush remaining content and clear timer on unmount
265
+ /** Whether this thread is on screen: a turn that outlives it ends without `onTurnEnd`. */
266
+ const mountedRef = useRef(false);
267
+
268
+ // Mounted, a turn that ends is announced (`onTurnEnd`); unmounting flushes and clears the timer.
264
269
  useEffect(() => {
270
+ mountedRef.current = true;
265
271
  return () => {
272
+ mountedRef.current = false;
266
273
  flushPendingUpdates();
267
274
  if (flushTimerRef.current) {
268
275
  clearTimeout(flushTimerRef.current);
@@ -613,7 +620,7 @@ export function useConversationStream({
613
620
 
614
621
  // The host's tools, and the outcomes of the actions it performed since the last request.
615
622
  const outcomes = hostOutcomesRef.current.pending();
616
- const hostMember = hostRequest(hostToolsRef.current, outcomes);
623
+ const hostMember = hostRequest(appRef.current, hostToolsRef.current, outcomes);
617
624
 
618
625
  // Authentication, the preflight refresh and the single 401 replay belong
619
626
  // to the linked Oxy client; this layer never reads or writes a bearer.
@@ -885,7 +892,11 @@ export function useConversationStream({
885
892
  // the next request. The stream does not wait for it.
886
893
  const action = parseHostAction(parsed);
887
894
  if (action !== null) {
888
- void hostOutcomesRef.current.dispatch(action, hostToolsRef.current);
895
+ void hostOutcomesRef.current.dispatch(
896
+ action,
897
+ hostToolsRef.current,
898
+ appRef.current,
899
+ );
889
900
  }
890
901
  continue;
891
902
  }
@@ -1344,6 +1355,8 @@ export function useConversationStream({
1344
1355
  * rendered messages, which have not caught up with the last batched flush.
1345
1356
  */
1346
1357
  if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1358
+ // Every way a turn stops streaming — finished, stopped, failed — passes here once.
1359
+ if (mountedRef.current) host.onTurnEnd?.();
1347
1360
  }
1348
1361
  },
1349
1362
  [
package/src/tools.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  normalizeCitationMarkers,
10
10
  } from './lib/citations';
11
11
  export { domainOf } from './lib/domain';
12
+ export { parseCatalogueTool, toolRendererKey } from './lib/catalogue-tool';
12
13
  export type { CitationSource } from './lib/citations';
13
14
  export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
14
15
  export {