@alia.onl/sdk 22.1.0 → 22.1.2

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,22 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 22.1.2
4
+
5
+ - **Every entry type-checks under `noUncheckedIndexedAccess`.** The SDK ships source, so a
6
+ consumer compiling with `"noUncheckedIndexedAccess": true` (Astro) type-checked the SDK under
7
+ it too and got errors from indexed reads in the runtime, thread, citation and catalogue-tool
8
+ modules (27 from `@alia.onl/sdk/runtime` alone). Each read is now narrowed where the index is
9
+ known to be in range, with a loop over the values where that is simpler. Behaviour is unchanged.
10
+ - `check:entry-types` compiles every entry with `noUncheckedIndexedAccess` on, so CI keeps it so.
11
+
12
+ ## 22.1.1
13
+
14
+ - A delayed Stop acknowledgement cannot idle a newer turn, replace its sidebar
15
+ preview, or notify a different conversation. Thread/account changes and unmounts
16
+ invalidate old completion callbacks while the original acknowledgement drains.
17
+ - Keep repeated Stop requests deduplicated during that drain; errors from an old
18
+ Stop cannot appear in a replacement thread. Public APIs and peer ranges are unchanged.
19
+
3
20
  ## 22.1.0
4
21
 
5
22
  - Stop explicitly cancels the authenticated foreground turn across API replicas;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "22.1.0",
3
+ "version": "22.1.2",
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",
@@ -554,7 +554,7 @@ export const AmbientField = memo(function AmbientField({
554
554
  key={config.w}
555
555
  config={config}
556
556
  index={index}
557
- color={palette[index]}
557
+ color={palette[index] as string}
558
558
  vw={vw}
559
559
  vh={vh}
560
560
  vmax={vmax}
@@ -121,7 +121,7 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
121
121
  switch (type) {
122
122
  case 'COMPACTLIST': {
123
123
  const title = match[1];
124
- const itemsText = match[2];
124
+ const itemsText = match[2] ?? '';
125
125
  const items = itemsText
126
126
  .split('\n')
127
127
  .filter((line) => line.trim().startsWith('-'))
@@ -140,26 +140,26 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
140
140
  return {
141
141
  type: match[1],
142
142
  title: match[2],
143
- content: match[3].trim(),
143
+ content: (match[3] ?? '').trim(),
144
144
  };
145
145
  }
146
146
  case 'COMPARISON': {
147
147
  const title = match[1];
148
- const content = match[2];
148
+ const content = match[2] ?? '';
149
149
  const leftMatch = content.match(/LEFT:\s*({.*?})/s);
150
150
  const rightMatch = content.match(/RIGHT:\s*({.*?})/s);
151
151
  const conclusionMatch = content.match(/CONCLUSION:\s*(.*?)$/s);
152
152
 
153
153
  return {
154
154
  title,
155
- left: leftMatch ? JSON.parse(leftMatch[1]) : {},
156
- right: rightMatch ? JSON.parse(rightMatch[1]) : {},
157
- conclusion: conclusionMatch ? conclusionMatch[1].trim() : undefined,
155
+ left: leftMatch ? JSON.parse(leftMatch[1] ?? '') : {},
156
+ right: rightMatch ? JSON.parse(rightMatch[1] ?? '') : {},
157
+ conclusion: conclusionMatch ? (conclusionMatch[1] ?? '').trim() : undefined,
158
158
  };
159
159
  }
160
160
  case 'TIMELINE': {
161
161
  const title = match[1];
162
- const itemsText = match[2];
162
+ const itemsText = match[2] ?? '';
163
163
  const items = itemsText
164
164
  .split('\n')
165
165
  .filter((line) => line.trim().startsWith('-'))
@@ -183,7 +183,7 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
183
183
  }
184
184
  case 'CREDIBILITY': {
185
185
  return {
186
- level: parseInt(match[1], 10),
186
+ level: parseInt(match[1] ?? '', 10),
187
187
  source: match[2],
188
188
  };
189
189
  }
@@ -30,7 +30,7 @@ export function ChartRenderer({ data, title }: ChartRendererProps) {
30
30
  labels.map((label, i) => ({ label, value: values[i] || 0 }));
31
31
 
32
32
  if (chartType === 'line' && datasets.length === 1) {
33
- const series = datasets[0];
33
+ const series = datasets[0] as (typeof datasets)[number];
34
34
  return (
35
35
  <LineChartCard
36
36
  title={series.label || title}
@@ -129,7 +129,7 @@ function StepsTab({ steps, lifecycle }: { steps: ThoughtStep[]; lifecycle: TurnL
129
129
  return <EmptyState variant="compact" description={t('thought.noSteps')} />;
130
130
  }
131
131
 
132
- const last = steps[steps.length - 1];
132
+ const last = steps[steps.length - 1] as (typeof steps)[number];
133
133
  const tail = live && (last.type === 'thinking' || last.type === 'writing') ? last : null;
134
134
  const rows = tail === null ? steps : steps.slice(0, -1);
135
135
 
@@ -213,7 +213,7 @@ export const AliaThread = memo(function AliaThread({
213
213
  if (index !== lastReplyIndex || index !== rows.length - 1) return false;
214
214
  if (index < history.length || failedTurn?.anchorMessageId === message.id) return false;
215
215
  for (let i = index - 1; i >= history.length; i--) {
216
- if (rows[i].role === 'user') return true;
216
+ if (rows[i]?.role === 'user') return true;
217
217
  }
218
218
  return false;
219
219
  };
@@ -223,7 +223,7 @@ export const AliaThread = memo(function AliaThread({
223
223
  * an old message belongs to the stretch that ended, not to the one on screen.
224
224
  */
225
225
  const conversationOf = (index: number): string | undefined =>
226
- index < history.length ? history[index].conversationId : conversationId;
226
+ index < history.length ? history[index]?.conversationId : conversationId;
227
227
 
228
228
  // ── Which rows are mounted, and the scroll that goes with them ──
229
229
  /** The row a cursor jump was aimed at, as an index into what is shown, or -1. */
@@ -169,7 +169,7 @@ export function useTimelineWindow({
169
169
  anchor: kept,
170
170
  offset: lastOffset.current ?? 0,
171
171
  atBottom: atBottom.current,
172
- probeId: rows[first],
172
+ probeId: rows[first] as string,
173
173
  });
174
174
  };
175
175
  }, [scrollKey]);
@@ -71,8 +71,8 @@ export function levelFromFrames(frames: readonly number[]): number {
71
71
  if (frames.length === 0) return 0;
72
72
 
73
73
  let sumSquares = 0;
74
- for (let index = 0; index < frames.length; index += 1) {
75
- sumSquares += frames[index] * frames[index];
74
+ for (const frame of frames) {
75
+ sumSquares += frame * frame;
76
76
  }
77
77
  const rms = Math.sqrt(sumSquares / frames.length);
78
78
 
@@ -12,7 +12,7 @@ const CANONICAL_TOOL = /^(?:oxy|self)_([a-z][a-z0-9-]{0,63})__([A-Za-z0-9_-]+)$/
12
12
  /** The app and tool of a canonical catalogue tool name, or `null` for any other name. */
13
13
  export function parseCatalogueTool(toolName: string): { app: string; tool: string } | null {
14
14
  const match = CANONICAL_TOOL.exec(toolName);
15
- return match ? { app: match[1], tool: match[2] } : null;
15
+ return match ? { app: match[1] ?? '', tool: match[2] ?? '' } : null;
16
16
  }
17
17
 
18
18
  /** The renderer key of a tool name (`<app>:<tool>`), or `null` when it is not a catalogue tool. */
@@ -180,7 +180,7 @@ export function splitReferences(
180
180
  const m = REFERENCE_LINE_RE.exec(line);
181
181
  if (m) {
182
182
  flush();
183
- current = { id: Number(m[1]), rest: m[2] };
183
+ current = { id: Number(m[1]), rest: m[2] ?? '' };
184
184
  } else if (current && line.trim().length > 0) {
185
185
  // A legacy entry's URL sits on the indented line after the title.
186
186
  current.rest += ` ${line.trim()}`;
@@ -275,13 +275,14 @@ function upsertOnTop(
275
275
  if (moveExisting) {
276
276
  // Remove the conversation from its current position (if it is in any page)
277
277
  for (let i = 0; i < pages.length; i++) {
278
- const existingIndex = pages[i].conversations.findIndex((c) => c.id === row.id);
278
+ const page = pages[i] as ConversationsPage;
279
+ const existingIndex = page.conversations.findIndex((c) => c.id === row.id);
279
280
  if (existingIndex >= 0) {
280
281
  pages[i] = {
281
- ...pages[i],
282
+ ...page,
282
283
  conversations: [
283
- ...pages[i].conversations.slice(0, existingIndex),
284
- ...pages[i].conversations.slice(existingIndex + 1),
284
+ ...page.conversations.slice(0, existingIndex),
285
+ ...page.conversations.slice(existingIndex + 1),
285
286
  ],
286
287
  };
287
288
  break;
@@ -347,7 +348,10 @@ export function useSaveConversation() {
347
348
  id,
348
349
  title: offlineTitle,
349
350
  lastMessage,
350
- createdAt: existingIndex >= 0 ? conversations[existingIndex].createdAt : new Date(),
351
+ createdAt:
352
+ existingIndex >= 0
353
+ ? (conversations[existingIndex] as Conversation).createdAt
354
+ : new Date(),
351
355
  updatedAt: new Date(),
352
356
  messages,
353
357
  };
@@ -102,7 +102,7 @@ export function normalizeConversationMessages(messages: readonly Message[]): Mes
102
102
  messages = messages.filter((message) => message.role !== 'system');
103
103
 
104
104
  for (let index = 0; index < messages.length; index += 1) {
105
- const current = messages[index];
105
+ const current = messages[index] as Message;
106
106
  const placeholder = messages[index + 1];
107
107
  const duplicate = messages[index + 2];
108
108
 
@@ -718,8 +718,9 @@ export function turnTiming(
718
718
  const index = messages.findIndex((m) => m.id === message.id);
719
719
  let sendStamp: number | null = null;
720
720
  for (let i = index - 1; i >= 0; i -= 1) {
721
- if (messages[i].role === 'user') {
722
- sendStamp = stampToMs(messages[i].createdAt);
721
+ const earlier = messages[i];
722
+ if (earlier?.role === 'user') {
723
+ sendStamp = stampToMs(earlier.createdAt);
723
724
  break;
724
725
  }
725
726
  }
@@ -95,7 +95,7 @@ export function threadHistory(
95
95
  ): ThreadMessage[] {
96
96
  const history: ThreadMessage[] = [];
97
97
  for (let page = pages.length - 1; page >= 0; page -= 1) {
98
- for (const message of pages[page].messages) {
98
+ for (const message of pages[page]?.messages ?? []) {
99
99
  if (message.conversationId === activeConversationId) continue;
100
100
  history.push({ ...message, id: message.id ?? message.cursor });
101
101
  }
@@ -76,7 +76,7 @@ function tailStart(count: number): number {
76
76
  /** The anchor that keeps exactly the rows mounted now, from the row at `start`. */
77
77
  export function pinAnchor(ids: readonly string[], start: number): TimelineAnchor {
78
78
  if (start <= 0 || ids.length === 0) return TOP;
79
- return { kind: 'row', id: ids[start] };
79
+ return { kind: 'row', id: ids[start] as string };
80
80
  }
81
81
 
82
82
  /**
@@ -124,7 +124,7 @@ export function sameTimelineShape(previous: readonly Shape[], next: readonly Sha
124
124
  if (previous === next) return true;
125
125
  if (previous.length !== next.length) return false;
126
126
  for (let i = 0; i < next.length; i += 1) {
127
- if (!sameShape(previous[i], next[i])) return false;
127
+ if (!sameShape(previous[i] as Shape, next[i] as Shape)) return false;
128
128
  }
129
129
  return true;
130
130
  }
@@ -125,8 +125,8 @@ function toPoints(series: readonly PricePoint[], intraday: boolean, locale: stri
125
125
 
126
126
  /** First-to-last change as a ratio, or none for a run that started at nothing. */
127
127
  function runDelta(series: readonly PricePoint[]): number | undefined {
128
- const first = series[0][1];
129
- const last = series[series.length - 1][1];
128
+ const first = (series[0] as PricePoint)[1];
129
+ const last = (series[series.length - 1] as PricePoint)[1];
130
130
  return first === 0 ? undefined : (last - first) / first;
131
131
  }
132
132
 
@@ -189,7 +189,7 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
189
189
  id,
190
190
  label: t(`market.range.${id}`),
191
191
  data: toPoints(run, id === '1D', locale),
192
- headline: quoted(data.price) ? data.price : run[run.length - 1][1],
192
+ headline: quoted(data.price) ? data.price : (run[run.length - 1] as PricePoint)[1],
193
193
  delta: id === '1D' && quoted(data.changePct) ? data.changePct / 100 : runDelta(run),
194
194
  });
195
195
  }
@@ -205,7 +205,7 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
205
205
  if (intraday.length >= 2) {
206
206
  notes.push(
207
207
  [
208
- `${t('market.open')} ${format(intraday[0])}`,
208
+ `${t('market.open')} ${format(intraday[0] as number)}`,
209
209
  `${t('market.dayHigh')} ${format(Math.max(...intraday))}`,
210
210
  `${t('market.dayLow')} ${format(Math.min(...intraday))}`,
211
211
  ].join(' · '),
@@ -220,7 +220,9 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
220
220
  return {
221
221
  title: `${data.name} · ${data.symbol}`,
222
222
  ranges,
223
- defaultRange: ranges.some((r) => r.id === '1D') ? '1D' : ranges[0].id,
223
+ defaultRange: ranges.some((r) => r.id === '1D')
224
+ ? '1D'
225
+ : (ranges[0] as (typeof ranges)[number]).id,
224
226
  format,
225
227
  formatAxisValue: (value) => formatAxis(value, locale),
226
228
  notes,
@@ -275,7 +277,7 @@ export function faircoinChart(
275
277
  id,
276
278
  label: t(`faircoin.range.${id}`),
277
279
  data: toPoints(run, id === '24h', locale),
278
- headline: quoted(data.price) ? data.price : run[run.length - 1][1],
280
+ headline: quoted(data.price) ? data.price : (run[run.length - 1] as PricePoint)[1],
279
281
  delta: id === '24h' && quoted(data.changePct) ? data.changePct / 100 : runDelta(run),
280
282
  });
281
283
  }
@@ -331,7 +333,9 @@ export function faircoinChart(
331
333
  chart: {
332
334
  title: `${t('faircoin.name')} · ${t('faircoin.symbol')}`,
333
335
  ranges,
334
- defaultRange: ranges.some((r) => r.id === '24h') ? '24h' : ranges[0].id,
336
+ defaultRange: ranges.some((r) => r.id === '24h')
337
+ ? '24h'
338
+ : (ranges[0] as (typeof ranges)[number]).id,
335
339
  format,
336
340
  formatAxisValue: (value) => formatAxis(value, locale),
337
341
  notes: [],
@@ -216,6 +216,8 @@ export function useConversationStream({
216
216
  const queryClient = useQueryClient();
217
217
  const abortControllerRef = useRef<AbortController | null>(null);
218
218
  const turnControlRef = useRef<ReturnType<typeof createChatTurnControl> | null>(null);
219
+ // A Stop acknowledgement can outlive its stream, thread, or the next send.
220
+ const turnGenerationRef = useRef(0);
219
221
 
220
222
  // Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
221
223
  const pendingContentRef = useRef('');
@@ -279,6 +281,8 @@ export function useConversationStream({
279
281
  mountedRef.current = true;
280
282
  return () => {
281
283
  mountedRef.current = false;
284
+ turnGenerationRef.current++;
285
+ turnControlRef.current = null;
282
286
  flushPendingUpdates();
283
287
  if (flushTimerRef.current) {
284
288
  clearTimeout(flushTimerRef.current);
@@ -324,6 +328,8 @@ export function useConversationStream({
324
328
  const was = conversationRef.current;
325
329
  conversationRef.current = conversationId;
326
330
  if (was !== undefined && was !== conversationId) {
331
+ turnGenerationRef.current++;
332
+ turnControlRef.current = null;
327
333
  hostOutcomesRef.current.clear();
328
334
  // The reply still streaming belongs to the thread that was left: cut it, drop what it had
329
335
  // batched (it would land on this thread's last message) and let the screen be idle.
@@ -350,6 +356,8 @@ export function useConversationStream({
350
356
  const was = accountRef.current;
351
357
  accountRef.current = accountId;
352
358
  if (was === null || was === accountId) return;
359
+ turnGenerationRef.current++;
360
+ turnControlRef.current = null;
353
361
  abortControllerRef.current?.abort();
354
362
  abortControllerRef.current = null;
355
363
  dropPendingUpdates();
@@ -367,6 +375,7 @@ export function useConversationStream({
367
375
 
368
376
  const append = useCallback(
369
377
  async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
378
+ const turnGeneration = ++turnGenerationRef.current;
370
379
  setIsLoading(true);
371
380
  setError(null);
372
381
  // A new send answers the old failure, whether it is the retry or a fresh
@@ -888,7 +897,7 @@ export function useConversationStream({
888
897
  const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
889
898
  if (idx >= 0) {
890
899
  invocations[idx] = {
891
- ...invocations[idx],
900
+ ...(invocations[idx] as ToolInvocation),
892
901
  state: 'result',
893
902
  result: output,
894
903
  };
@@ -1245,7 +1254,11 @@ export function useConversationStream({
1245
1254
  const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
1246
1255
 
1247
1256
  if (idx >= 0) {
1248
- invocations[idx] = { ...invocations[idx], state: 'result', result: output };
1257
+ invocations[idx] = {
1258
+ ...(invocations[idx] as ToolInvocation),
1259
+ state: 'result',
1260
+ result: output,
1261
+ };
1249
1262
  } else {
1250
1263
  invocations.push({
1251
1264
  toolCallId: tool_call_id,
@@ -1376,33 +1389,31 @@ export function useConversationStream({
1376
1389
  setError(finalError);
1377
1390
  return keepFailedTurn(true, finalError.message);
1378
1391
  } finally {
1379
- // Flush any remaining batched content
1380
- flushPendingUpdates();
1381
- if (flushTimerRef.current) {
1382
- clearTimeout(flushTimerRef.current);
1383
- flushTimerRef.current = null;
1392
+ // Batches belong to the current turn. An older finally must not flush or clear
1393
+ // the next turn's shared timer, even when its controller ref has been cleared.
1394
+ if (turnGenerationRef.current === turnGeneration) {
1395
+ flushPendingUpdates();
1396
+ if (flushTimerRef.current) {
1397
+ clearTimeout(flushTimerRef.current);
1398
+ flushTimerRef.current = null;
1399
+ }
1384
1400
  }
1385
- // Only the turn that still owns the ref clears it and the loading flag: after Stop and a new
1386
- // send, this `finally` belongs to the old turn and must not idle the new one.
1387
- const ownsTurn =
1388
- abortControllerRef.current === null || abortControllerRef.current === controller;
1389
- if (abortControllerRef.current === controller) abortControllerRef.current = null;
1390
- if (turnControlRef.current === turnControl) turnControlRef.current = null;
1401
+ // Settle only this turn's message ids, including when a new send has already
1402
+ // started in the same conversation. Error paths may have settled them first.
1403
+ settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1404
+ // Retain this control while its ACK drains so repeated Stop remains deduplicated.
1405
+ // Local transport cleanup is required even after the thread has been replaced.
1391
1406
  if (turnControl) await turnControl.finish();
1392
1407
  else linked?.dispose();
1393
- // A turn that is still streaming here ended without an error path
1394
- // settling it: on its own, or because `stop()` aborted the controller.
1395
- // (The error paths abort it too, but they have settled it first.)
1396
- settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1397
- if (ownsTurn) setIsLoading(false);
1398
- /*
1399
- * The answer replaces your own line in the sidebar — once per turn rather
1400
- * than once per token. Read from what this turn received, not from the
1401
- * rendered messages, which have not caught up with the last batched flush.
1402
- */
1403
- if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1404
- // Every way a turn stops streaming — finished, stopped, failed — passes here once.
1405
- if (mountedRef.current) host.onTurnEnd?.();
1408
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
1409
+ if (turnControlRef.current === turnControl) turnControlRef.current = null;
1410
+ // Recheck after the await: another turn, thread, account or mount may now own UI.
1411
+ if (mountedRef.current && turnGenerationRef.current === turnGeneration) {
1412
+ setIsLoading(false);
1413
+ // The sidebar preview and completion notification belong to this visible turn.
1414
+ if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1415
+ host.onTurnEnd?.();
1416
+ }
1406
1417
  }
1407
1418
  },
1408
1419
  [
@@ -1434,9 +1445,14 @@ export function useConversationStream({
1434
1445
 
1435
1446
  const stop = useCallback(() => {
1436
1447
  const turn = turnControlRef.current;
1448
+ const turnGeneration = turnGenerationRef.current;
1437
1449
  if (!turn) return;
1438
1450
  void turn.stop().catch((error: unknown) => {
1439
- if (turnControlRef.current === turn && mountedRef.current) {
1451
+ if (
1452
+ turnControlRef.current === turn &&
1453
+ turnGenerationRef.current === turnGeneration &&
1454
+ mountedRef.current
1455
+ ) {
1440
1456
  setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
1441
1457
  }
1442
1458
  });
@@ -435,7 +435,8 @@ export function useConversationThread({
435
435
  (assistantMessageId: string): Message | undefined => {
436
436
  const idx = messages.findIndex((msg) => msg.id === assistantMessageId);
437
437
  for (let i = idx - 1; i >= 0; i--) {
438
- if (messages[i].role === 'user') return messages[i];
438
+ const earlier = messages[i];
439
+ if (earlier?.role === 'user') return earlier;
439
440
  }
440
441
  return undefined;
441
442
  },