@alia.onl/sdk 15.8.0 → 19.0.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 (141) hide show
  1. package/CHANGELOG.md +242 -13
  2. package/README.md +223 -151
  3. package/package.json +15 -5
  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 +0 -3
  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 +76 -34
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +32 -14
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +123 -0
  91. package/src/runtime/attachment-utils.ts +96 -0
  92. package/src/runtime/composer-draft-store.ts +270 -0
  93. package/src/runtime/config.tsx +186 -0
  94. package/src/runtime/conversation-store.ts +49 -0
  95. package/src/runtime/conversations.ts +557 -0
  96. package/src/runtime/error-utils.ts +104 -0
  97. package/src/runtime/host-tools.ts +140 -0
  98. package/src/runtime/index.ts +113 -0
  99. package/src/runtime/message-history.ts +124 -0
  100. package/src/runtime/model.ts +39 -0
  101. package/src/runtime/oxy-files.ts +37 -0
  102. package/src/runtime/power-levels.ts +66 -0
  103. package/src/runtime/query-keys.ts +34 -0
  104. package/src/runtime/random-uuid.ts +25 -0
  105. package/src/runtime/routes.ts +51 -0
  106. package/src/runtime/sharing.ts +165 -0
  107. package/src/runtime/sse-frame-reader.ts +88 -0
  108. package/src/runtime/stream-events.ts +146 -0
  109. package/src/runtime/stream-outcome.ts +21 -0
  110. package/src/runtime/thought-store.ts +103 -0
  111. package/src/runtime/thread/agent-activity.ts +261 -0
  112. package/src/runtime/thread/automation-labels.ts +79 -0
  113. package/src/runtime/thread/conversation-export.ts +135 -0
  114. package/src/runtime/thread/message-days.ts +120 -0
  115. package/src/runtime/thread/message-processor.ts +93 -0
  116. package/src/runtime/thread/task-utils.ts +11 -0
  117. package/src/runtime/thread/thought-utils.ts +752 -0
  118. package/src/runtime/thread/thread-history.ts +139 -0
  119. package/src/runtime/thread/timeline.ts +202 -0
  120. package/src/runtime/thread/title-tags.ts +8 -0
  121. package/src/runtime/thread/tool-cards.ts +383 -0
  122. package/src/runtime/turn-failure.ts +108 -0
  123. package/src/runtime/types.ts +148 -0
  124. package/src/runtime/usage-limit-error.ts +44 -0
  125. package/src/runtime/use-agent-activity.ts +80 -0
  126. package/src/runtime/use-conversation-stream.ts +1427 -0
  127. package/src/runtime/use-conversation-thread.ts +556 -0
  128. package/src/runtime/use-start-conversation.ts +107 -0
  129. package/src/runtime/use-thread-history.ts +64 -0
  130. package/src/runtime/use-thread-search.ts +95 -0
  131. package/src/runtime/use-turn-edit.ts +99 -0
  132. package/src/tools.ts +2 -2
  133. package/src/types.ts +18 -47
  134. package/src/components/AliaChatMessageList.tsx +0 -532
  135. package/src/components/ThinkingBlock.tsx +0 -43
  136. package/src/hooks/useAliaChat.ts +0 -573
  137. package/src/lib/catalogue.ts +0 -236
  138. package/src/lib/chat-stream.ts +0 -655
  139. package/src/lib/chat-transport.ts +0 -98
  140. package/src/lib/delegation.ts +0 -34
  141. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,752 @@
1
+ import { getToolLabel } from '../../lib/tool-registry';
2
+ import type { FailedTurn } from '../turn-failure';
3
+ import type { Message, ToolInvocation } from '../types';
4
+
5
+ export interface Source {
6
+ title: string;
7
+ url: string;
8
+ snippet: string;
9
+ domain: string;
10
+ }
11
+
12
+ export interface ThoughtStep {
13
+ /**
14
+ * `thinking`, `tool` and `writing` are phases of the work; the other three are
15
+ * how it ENDED, and at most one of them closes a list. The panel translates
16
+ * every non-tool type itself — `label` is the English default, kept so a
17
+ * caller without a translator still has something to print.
18
+ */
19
+ type: 'thinking' | 'tool' | 'writing' | 'done' | 'failed' | 'cancelled';
20
+ label: string;
21
+ toolName?: string;
22
+ sources?: Source[];
23
+ state?: 'partial-call' | 'call' | 'result';
24
+ /**
25
+ * The invocation a tool step was built from, so a row can open its input
26
+ * and output in place without a second lookup by position. Only tool steps
27
+ * carry one.
28
+ */
29
+ invocation?: ToolInvocation;
30
+ }
31
+
32
+ /**
33
+ * Where a turn is in its life, as the runtime knows it — never as guessed
34
+ * from what the message happens to contain.
35
+ *
36
+ * - `queued`: the turn is in flight and nothing has arrived for it yet.
37
+ * - `running`: tokens, reasoning or tool calls are arriving.
38
+ * - `completed`: the run ended on its own terms.
39
+ * - `failed`: the run ended with an error — the failed-turn card is its twin.
40
+ * - `cancelled`: the person stopped it, or a persisted turn shows a tool that
41
+ * never returned, which is a run that was interrupted before it finished.
42
+ */
43
+ export type TurnLifecycle = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
44
+
45
+ /** The three ways a settled turn can have ended, as stamped on its message. */
46
+ export type TurnOutcome = NonNullable<Message['turnOutcome']>;
47
+
48
+ /**
49
+ * What the streaming runtime knows about the conversation a message sits in.
50
+ *
51
+ * All of it is optional because a message read back from the server after a
52
+ * reload has none of it — and a message with no live context is, by
53
+ * definition, one whose turn is over.
54
+ */
55
+ export interface TurnContext {
56
+ /** The hook's `isLoading`: a turn is in flight in THIS conversation. */
57
+ isLoading?: boolean;
58
+ /**
59
+ * The message is the last assistant message of the conversation, which is
60
+ * the one a running turn writes into. Only meaningful with `isLoading`.
61
+ */
62
+ isLastAssistant?: boolean;
63
+ /** The turn that got no answer, if any — its anchor names the failed message. */
64
+ failedTurn?: FailedTurn | null;
65
+ }
66
+
67
+ /** The fields of a message the lifecycle is read from; a `Message` satisfies it. */
68
+ export type LifecycleMessage = Pick<
69
+ Message,
70
+ | 'id'
71
+ | 'content'
72
+ | 'thinking'
73
+ | 'toolInvocations'
74
+ | 'isStreaming'
75
+ | 'turnOutcome'
76
+ | 'researchProgress'
77
+ >;
78
+
79
+ /** A tool that was called and never came back — `call` or `partial-call`. */
80
+ function hasUnresolvedTool(message: Pick<LifecycleMessage, 'toolInvocations'>): boolean {
81
+ return message.toolInvocations?.some((inv) => inv.state !== 'result') ?? false;
82
+ }
83
+
84
+ function hasContent(content: LifecycleMessage['content'] | undefined): boolean {
85
+ return typeof content === 'string'
86
+ ? content.length > 0
87
+ : Array.isArray(content) && content.length > 0;
88
+ }
89
+
90
+ /** Anything at all has arrived for the turn: text, reasoning, a tool call or a research phase. */
91
+ function hasAnyOutput(message: LifecycleMessage): boolean {
92
+ return (
93
+ hasContent(message.content) ||
94
+ (message.thinking !== undefined && message.thinking.length > 0) ||
95
+ (message.toolInvocations?.length ?? 0) > 0 ||
96
+ message.researchProgress !== undefined
97
+ );
98
+ }
99
+
100
+ /**
101
+ * The lifecycle of a turn, from the signals the runtime actually tracks.
102
+ *
103
+ * The panel used to decide a turn had ended by looking at whether the message
104
+ * had content — so the first streamed token flipped a running answer to
105
+ * "Done", a finished tool-only turn looked like it was still running, and
106
+ * array content counted as finished the moment it existed. None of those
107
+ * are the turn's state; they are what the message happened to hold.
108
+ *
109
+ * The order here is the order of certainty:
110
+ *
111
+ * 1. A settled outcome on the message, or the failed-turn card anchored on
112
+ * it, is final — a stream cannot un-fail.
113
+ * 2. Otherwise the turn is live while its message is being streamed into.
114
+ * `isStreaming` is the hook's own stamp and is trusted on its own, EXCEPT
115
+ * against an explicit `isLoading: false`: a stamp that outlived the turn
116
+ * (persisted mid-stream, or left on a voice row) must not run forever.
117
+ * The `isLoading && isLastAssistant` pair is the fallback for a message
118
+ * that carries no stamp at all.
119
+ * 3. With no live signal the turn is over. A tool still in `call` then is
120
+ * one that never returned — an interrupted run, not a finished one.
121
+ */
122
+ export function turnLifecycle(message: LifecycleMessage, ctx: TurnContext = {}): TurnLifecycle {
123
+ if (message.turnOutcome === 'failed' || ctx.failedTurn?.anchorMessageId === message.id)
124
+ return 'failed';
125
+ if (message.turnOutcome === 'cancelled') return 'cancelled';
126
+ if (message.turnOutcome === 'completed') return 'completed';
127
+
128
+ const stamped = message.isStreaming === true && ctx.isLoading !== false;
129
+ const inferred = ctx.isLoading === true && ctx.isLastAssistant === true;
130
+ if (stamped || inferred) return hasAnyOutput(message) ? 'running' : 'queued';
131
+
132
+ return hasUnresolvedTool(message) ? 'cancelled' : 'completed';
133
+ }
134
+
135
+ /** The lifecycles during which the panel still animates. */
136
+ export function isLiveLifecycle(lifecycle: TurnLifecycle): boolean {
137
+ return lifecycle === 'queued' || lifecycle === 'running';
138
+ }
139
+
140
+ function getDomain(url: string): string {
141
+ try {
142
+ return new URL(url).hostname.replace('www.', '');
143
+ } catch {
144
+ return url;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The sources of a finished `deepResearch` invocation, as the research
150
+ * handler persists them: `{ id, url, title }[]` under `result.sources`, the
151
+ * same shape the final `alia.research_progress` event carries live. No
152
+ * snippet — research keeps excerpts server-side — so the card shows the
153
+ * title alone.
154
+ */
155
+ function researchInvocationSources(result: { sources?: unknown }): Source[] {
156
+ if (!Array.isArray(result.sources)) return [];
157
+ return researchSourcesToSources(
158
+ result.sources as Array<{ id?: unknown; url?: unknown; title?: unknown }>,
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Research sources — from a persisted invocation or from the live progress
164
+ * event — in the shape the Sources row and panel render. One per URL: the
165
+ * tracker already de-duplicates server-side, and this keeps that true of
166
+ * whatever a row holds.
167
+ */
168
+ export function researchSourcesToSources(
169
+ sources?: Array<{ id?: unknown; url?: unknown; title?: unknown }> | null,
170
+ ): Source[] {
171
+ if (!sources) return [];
172
+ const seen = new Set<string>();
173
+ const out: Source[] = [];
174
+ for (const s of sources) {
175
+ if (!s || typeof s.url !== 'string' || s.url.length === 0 || seen.has(s.url)) continue;
176
+ seen.add(s.url);
177
+ const title =
178
+ typeof s.title === 'string' && s.title.trim().length > 0 ? s.title.trim() : getDomain(s.url);
179
+ out.push({ title, url: s.url, snippet: '', domain: getDomain(s.url) });
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** The union of two source lists, first occurrence of a URL winning. */
185
+ export function mergeSources(...lists: Source[][]): Source[] {
186
+ const seen = new Set<string>();
187
+ const out: Source[] = [];
188
+ for (const list of lists) {
189
+ for (const source of list) {
190
+ if (seen.has(source.url)) continue;
191
+ seen.add(source.url);
192
+ out.push(source);
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Extract unique sources from tool invocations (webSearch, webScraper,
200
+ * browse, and the `deepResearch` record a research answer is saved with).
201
+ */
202
+ export function extractSources(toolInvocations?: ToolInvocation[]): Source[] {
203
+ if (!toolInvocations) return [];
204
+
205
+ const seen = new Set<string>();
206
+ const sources: Source[] = [];
207
+
208
+ for (const inv of toolInvocations) {
209
+ if (inv.state !== 'result' || !inv.result) continue;
210
+
211
+ if (inv.toolName === 'deepResearch') {
212
+ for (const source of researchInvocationSources(inv.result)) {
213
+ if (seen.has(source.url)) continue;
214
+ seen.add(source.url);
215
+ sources.push(source);
216
+ }
217
+ continue;
218
+ }
219
+
220
+ if (
221
+ (inv.toolName === 'webSearch' ||
222
+ (inv.toolName === 'browse' && inv.result.action === 'search')) &&
223
+ Array.isArray(inv.result.results)
224
+ ) {
225
+ for (const r of inv.result.results) {
226
+ if (r.url && !seen.has(r.url)) {
227
+ seen.add(r.url);
228
+ sources.push({
229
+ title: r.title || getDomain(r.url),
230
+ url: r.url,
231
+ snippet: r.snippet || '',
232
+ domain: getDomain(r.url),
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ if (inv.toolName === 'browse' && inv.result.action === 'read' && inv.result.url) {
239
+ const url = inv.result.url;
240
+ if (!seen.has(url)) {
241
+ seen.add(url);
242
+ sources.push({
243
+ title: inv.result.title || getDomain(url),
244
+ url,
245
+ snippet: inv.result.content ? inv.result.content.slice(0, 200) : '',
246
+ domain: getDomain(url),
247
+ });
248
+ }
249
+ }
250
+
251
+ if (inv.toolName === 'webScraper' && inv.result.url) {
252
+ const url = inv.result.url;
253
+ if (!seen.has(url)) {
254
+ seen.add(url);
255
+ sources.push({
256
+ title: inv.result.title || getDomain(url),
257
+ url,
258
+ snippet: inv.result.content ? inv.result.content.slice(0, 200) : '',
259
+ domain: getDomain(url),
260
+ });
261
+ }
262
+ }
263
+ }
264
+
265
+ return sources;
266
+ }
267
+
268
+ /**
269
+ * Build an ordered timeline of steps from a message's thinking + tool
270
+ * invocations, closed by the step its lifecycle earns.
271
+ *
272
+ * The closing step is decided by the LIFECYCLE and nothing else: text in the
273
+ * message does not mean the turn is over (it is "Writing" while the turn
274
+ * runs), and a turn that ended in an error or a stop never reads "Done" — it
275
+ * gets its own terminal step. A tool still running is left in its own state
276
+ * so a finished tool before it does not hide it.
277
+ */
278
+ export function buildSteps(
279
+ message: Partial<Pick<LifecycleMessage, 'thinking' | 'content' | 'toolInvocations'>>,
280
+ lifecycle: TurnLifecycle,
281
+ ): ThoughtStep[] {
282
+ const steps: ThoughtStep[] = [];
283
+
284
+ // 1. Thinking step — also what a queued turn shows, since a turn with nothing
285
+ // to show yet is nonetheless being thought about.
286
+ if (message.thinking || lifecycle === 'queued') {
287
+ steps.push({ type: 'thinking', label: 'Thinking' });
288
+ }
289
+
290
+ // 2. Tool invocation steps
291
+ if (message.toolInvocations) {
292
+ for (const inv of message.toolInvocations) {
293
+ const step: ThoughtStep = {
294
+ type: 'tool',
295
+ label: getToolLabel(inv.toolName),
296
+ toolName: inv.toolName,
297
+ state: inv.state,
298
+ invocation: inv,
299
+ };
300
+
301
+ // A research step carries every source the answer was written from.
302
+ if (inv.toolName === 'deepResearch' && inv.state === 'result' && inv.result) {
303
+ const researchSources = researchInvocationSources(inv.result);
304
+ if (researchSources.length > 0) step.sources = researchSources;
305
+ }
306
+
307
+ // Attach sources for search tools that have results
308
+ if (
309
+ (inv.toolName === 'webSearch' ||
310
+ (inv.toolName === 'browse' && inv.result?.action === 'search')) &&
311
+ inv.state === 'result' &&
312
+ inv.result?.results
313
+ ) {
314
+ step.sources = inv.result.results
315
+ .filter((r: any) => r.url)
316
+ .map((r: any) => ({
317
+ title: r.title || getDomain(r.url),
318
+ url: r.url,
319
+ snippet: r.snippet || '',
320
+ domain: getDomain(r.url),
321
+ }));
322
+ }
323
+
324
+ steps.push(step);
325
+ }
326
+ }
327
+
328
+ // 3. The step the lifecycle closes the list with.
329
+ switch (lifecycle) {
330
+ case 'queued':
331
+ break;
332
+ case 'running':
333
+ // A tool that has not returned is the live step; otherwise the model is
334
+ // writing, whether or not its first token has been flushed yet.
335
+ if (!hasUnresolvedTool(message)) steps.push({ type: 'writing', label: 'Writing' });
336
+ break;
337
+ case 'completed':
338
+ // "Done" under a message that holds nothing at all would be a step
339
+ // about nothing; a finished tool-only turn, though, is done.
340
+ if (steps.length > 0 || hasContent(message.content))
341
+ steps.push({ type: 'done', label: 'Done' });
342
+ break;
343
+ case 'failed':
344
+ steps.push({ type: 'failed', label: 'Failed' });
345
+ break;
346
+ case 'cancelled':
347
+ steps.push({ type: 'cancelled', label: 'Stopped' });
348
+ break;
349
+ }
350
+
351
+ return steps;
352
+ }
353
+
354
+ /**
355
+ * Text an audit row shows, before it is translated.
356
+ *
357
+ * The timeline is built outside React, with no translator, and it used to
358
+ * build its rows' words in English right here ("Plan", "3 steps",
359
+ * "Research: synthesizing") — so the Activity tab was English in every
360
+ * locale. It now says WHAT to show and the panel says it:
361
+ *
362
+ * - `key`: an i18n key and its params.
363
+ * - `tool`: a tool's own label, by tool name — see `toolLabel`.
364
+ * - `text`: words that are the conversation's own (a query, a file name, a
365
+ * server's progress message), shown as they are.
366
+ */
367
+ export type AuditText =
368
+ | { key: string; params?: Record<string, string | number> }
369
+ | { tool: string }
370
+ | { text: string };
371
+
372
+ /**
373
+ * The tools this app has its own words for, in every locale.
374
+ *
375
+ * A set rather than a lookup into the catalog: `toolName` is an open string
376
+ * off the wire, and `thought.tool.constructor` would resolve to
377
+ * `Object.prototype.constructor` inside i18n-js. Anything else falls back to
378
+ * the SDK registry's English label, then to the raw name.
379
+ */
380
+ const TRANSLATED_TOOLS: ReadonlySet<string> = new Set([
381
+ 'webSearch',
382
+ 'scrapeURL',
383
+ 'getTimeline',
384
+ 'searchKnowledgeBase',
385
+ 'webScraper',
386
+ 'browse',
387
+ 'sendWhatsAppMessage',
388
+ 'getWhatsAppChats',
389
+ 'getWhatsAppMessages',
390
+ 'sendTelegramMessage',
391
+ 'getCurrentDate',
392
+ 'generateFile',
393
+ 'saveUserMemory',
394
+ 'updateUserPreferences',
395
+ 'updateUserContext',
396
+ ]);
397
+
398
+ type Translate = (key: string, params?: Record<string, unknown>) => string;
399
+
400
+ /** A tool's label in the reader's language, where the app has one. */
401
+ export function toolLabel(toolName: string, t: Translate): string {
402
+ return TRANSLATED_TOOLS.has(toolName) ? t(`thought.tool.${toolName}`) : getToolLabel(toolName);
403
+ }
404
+
405
+ /** An `AuditText`, as the words a row shows. */
406
+ export function auditText(text: AuditText, t: Translate): string {
407
+ if ('key' in text) return t(text.key, text.params);
408
+ if ('tool' in text) return toolLabel(text.tool, t);
409
+ return text.text;
410
+ }
411
+
412
+ /** Research phases the Activity tab names; any other reads as "Researching". */
413
+ const RESEARCH_PHASES: ReadonlySet<string> = new Set([
414
+ 'decomposing',
415
+ 'searching',
416
+ 'reading',
417
+ 'synthesizing',
418
+ 'follow_up',
419
+ 'finalizing',
420
+ ]);
421
+
422
+ /**
423
+ * Entry in the action audit timeline.
424
+ */
425
+ export interface AuditEntry {
426
+ id: string;
427
+ type: 'tool_call' | 'research_phase' | 'agent_delegation' | 'plan' | 'artifact_generated';
428
+ label: AuditText;
429
+ description: AuditText;
430
+ /** `interrupted` is a tool call that never returned in a turn that is over. */
431
+ status: 'in_progress' | 'complete' | 'interrupted';
432
+ toolName?: string;
433
+ messageId: string;
434
+ }
435
+
436
+ /**
437
+ * Build a chronological audit timeline from all conversation messages.
438
+ *
439
+ * A tool call without a result is "in progress" only while its turn is
440
+ * actually running; in a turn that ended — stopped, failed, or read back from
441
+ * the server that way — it is a call that never returned, and it must not
442
+ * pulse forever.
443
+ */
444
+ export function buildAuditTimeline(
445
+ messages: Message[],
446
+ ctx: Omit<TurnContext, 'isLastAssistant'> = {},
447
+ ): AuditEntry[] {
448
+ const entries: AuditEntry[] = [];
449
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
450
+
451
+ for (const msg of messages) {
452
+ if (msg.role !== 'assistant') continue;
453
+ const live = isLiveLifecycle(
454
+ turnLifecycle(msg, { ...ctx, isLastAssistant: msg === lastAssistant }),
455
+ );
456
+
457
+ // Agent delegation
458
+ if (msg.agentInfo) {
459
+ entries.push({
460
+ id: `agent-${msg.id}`,
461
+ type: 'agent_delegation',
462
+ label: { key: 'thought.audit.agent', params: { name: msg.agentInfo.name } },
463
+ description: { text: typeof msg.content === 'string' ? msg.content.slice(0, 80) : '' },
464
+ status: 'complete',
465
+ messageId: msg.id,
466
+ });
467
+ }
468
+
469
+ // The plan it showed
470
+ if (msg.plan) {
471
+ entries.push({
472
+ id: `plan-${msg.id}`,
473
+ type: 'plan',
474
+ label: { key: 'thought.audit.plan' },
475
+ description: {
476
+ key: 'thought.audit.planSteps',
477
+ params: { count: msg.plan.steps?.length || 0 },
478
+ },
479
+ status: 'complete',
480
+ messageId: msg.id,
481
+ });
482
+ }
483
+
484
+ // Tool invocations
485
+ if (msg.toolInvocations) {
486
+ for (const inv of msg.toolInvocations) {
487
+ const isDone = inv.state === 'result';
488
+ let description = '';
489
+ if (inv.args?.query) {
490
+ const q = String(inv.args.query);
491
+ description = q.length > 50 ? q.slice(0, 50) + '...' : q;
492
+ } else if (inv.args?.url) {
493
+ const u = String(inv.args.url);
494
+ description = u.length > 50 ? u.slice(0, 50) + '...' : u;
495
+ }
496
+
497
+ entries.push({
498
+ id: inv.toolCallId || `tool-${msg.id}-${inv.toolName}`,
499
+ type: 'tool_call',
500
+ label: { tool: inv.toolName },
501
+ description: { text: description },
502
+ status: isDone ? 'complete' : live ? 'in_progress' : 'interrupted',
503
+ toolName: inv.toolName,
504
+ messageId: msg.id,
505
+ });
506
+
507
+ // Artifact generated from generateFile
508
+ if (inv.toolName === 'generateFile' && isDone && inv.result) {
509
+ entries.push({
510
+ id: `artifact-${inv.toolCallId}`,
511
+ type: 'artifact_generated',
512
+ label: { key: 'thought.audit.fileGenerated' },
513
+ description: { text: inv.result.filename || inv.result.title || '' },
514
+ status: 'complete',
515
+ messageId: msg.id,
516
+ });
517
+ }
518
+ }
519
+ }
520
+
521
+ // Research phases. `failed` is the engine saying the write-up did not
522
+ // happen: finished, not in progress, and not "complete" either.
523
+ if (msg.researchProgress) {
524
+ const rp = msg.researchProgress;
525
+ const failed = rp.phase === 'failed';
526
+ entries.push({
527
+ id: `research-${msg.id}`,
528
+ type: 'research_phase',
529
+ label: failed
530
+ ? { key: 'thought.audit.researchIncomplete' }
531
+ : rp.isComplete
532
+ ? { key: 'thought.audit.researchComplete' }
533
+ : rp.phase !== undefined && RESEARCH_PHASES.has(rp.phase)
534
+ ? { key: `thought.audit.researchPhase.${rp.phase}` }
535
+ : { key: 'thought.audit.researching' },
536
+ description: { text: rp.message || '' },
537
+ status: rp.isComplete || failed ? 'complete' : 'in_progress',
538
+ messageId: msg.id,
539
+ });
540
+ }
541
+ }
542
+
543
+ return entries;
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Execution rows (#544): what one tool call reads as, what a turn produced,
548
+ // and how long it worked.
549
+ // ---------------------------------------------------------------------------
550
+
551
+ /**
552
+ * How a single tool call reads in an execution row (`running`, `done`, `error`,
553
+ * `interrupted`). One definition, the SDK's, so its chat and this app agree.
554
+ */
555
+ export { type ToolCallStatus, toolCallStatus } from '../../tools';
556
+
557
+ /** Upper bound on the text an expanded row prints for one side of a call. */
558
+ const TOOL_TEXT_LIMIT = 4000;
559
+
560
+ /**
561
+ * One side of a tool call — its arguments or its result — as text a reader
562
+ * can scan. Strings are printed as they are; anything else is pretty JSON.
563
+ * Long output is cut with a marker rather than hidden, so a row never shows
564
+ * a fragment as if it were the whole.
565
+ */
566
+ export function toolCallText(value: unknown, limit = TOOL_TEXT_LIMIT): string {
567
+ if (value === undefined || value === null) return '';
568
+ let text: string;
569
+ if (typeof value === 'string') {
570
+ text = value;
571
+ } else {
572
+ try {
573
+ text = JSON.stringify(value, null, 2) ?? '';
574
+ } catch {
575
+ text = String(value);
576
+ }
577
+ }
578
+ return text.length > limit ? `${text.slice(0, limit)}\n…` : text;
579
+ }
580
+
581
+ /** A file a turn produced, as the Outputs section lists it. */
582
+ export interface OutputFile {
583
+ /** The invocation's id, which is also the canvas artifact's id when one was made. */
584
+ id: string;
585
+ name: string;
586
+ toolName: string;
587
+ }
588
+
589
+ /**
590
+ * The files a message's tools generated, read off the same persisted
591
+ * `toolInvocations` the sources come from: a finished `generateFile` names
592
+ * its file, and any tool that returned an `artifact` names that. Nothing is
593
+ * invented for a call that did not return — a running or interrupted
594
+ * generation is a step, not an output.
595
+ */
596
+ export function extractOutputs(toolInvocations?: ToolInvocation[]): OutputFile[] {
597
+ if (!toolInvocations) return [];
598
+ const outputs: OutputFile[] = [];
599
+ for (const inv of toolInvocations) {
600
+ if (inv.state !== 'result' || !inv.result || typeof inv.result !== 'object') continue;
601
+ const result = inv.result as {
602
+ filename?: unknown;
603
+ title?: unknown;
604
+ artifact?: { title?: unknown } | null;
605
+ };
606
+ let name: string | null = null;
607
+ if (inv.toolName === 'generateFile') {
608
+ name =
609
+ typeof result.filename === 'string' && result.filename.length > 0
610
+ ? result.filename
611
+ : typeof result.title === 'string' && result.title.length > 0
612
+ ? result.title
613
+ : getToolLabel(inv.toolName);
614
+ } else if (result.artifact && typeof result.artifact === 'object') {
615
+ name =
616
+ typeof result.artifact.title === 'string' && result.artifact.title.length > 0
617
+ ? result.artifact.title
618
+ : getToolLabel(inv.toolName);
619
+ }
620
+ if (name === null) continue;
621
+ outputs.push({
622
+ id: inv.toolCallId || `output-${outputs.length}`,
623
+ name,
624
+ toolName: inv.toolName,
625
+ });
626
+ }
627
+ return outputs;
628
+ }
629
+
630
+ /**
631
+ * When a turn started and, if the conversation says, when it ended — both as
632
+ * epoch milliseconds, `null` where the conversation does not say.
633
+ *
634
+ * The start is the send: the user message before this one carries the
635
+ * client's own stamp (the hook writes it and `POST /conversations` keeps it).
636
+ * The end is the assistant row's stamp, which the server writes when the
637
+ * turn is SAVED — after the model finished — so on a reloaded thread the two
638
+ * bracket the work. A message the client has only just appended carries the
639
+ * send time on both rows, and an older thread saved before clients stamped
640
+ * their sends carries the save time on both; either way the gap is nothing,
641
+ * which is not an elapsed time. Under a second is therefore read as "the
642
+ * conversation does not say".
643
+ */
644
+ export interface TurnTiming {
645
+ startedAt: number | null;
646
+ endedAt: number | null;
647
+ }
648
+
649
+ const MIN_PERSISTED_ELAPSED_MS = 1000;
650
+
651
+ function stampToMs(stamp: string | undefined): number | null {
652
+ if (stamp === undefined) return null;
653
+ const ms = Date.parse(stamp);
654
+ return Number.isNaN(ms) ? null : ms;
655
+ }
656
+
657
+ /**
658
+ * Every turn's timing, in one pass over the thread.
659
+ *
660
+ * ## Why this exists beside `turnTiming`
661
+ *
662
+ * `turnTiming` answers for ONE message and has to find it first, so it opens
663
+ * with `messages.findIndex(...)` and then walks backwards for the send that
664
+ * started the turn. Both are scans. That is the right shape for the execution
665
+ * panel, which asks about a single message a person has opened.
666
+ *
667
+ * The thread asks about all of them. The thread once called it once per
668
+ * row, inside the render, with the WHOLE list as the second argument — so
669
+ * drawing n messages cost O(n²) comparisons, and it paid that on every render.
670
+ * The list re-renders roughly twenty times a second while an answer streams,
671
+ * which is where this stops being a curiosity: at a thousand messages the
672
+ * lookup alone is on the order of half a million comparisons per frame, for an
673
+ * answer that does not change as tokens arrive.
674
+ *
675
+ * One pass, carrying the last user stamp forward, gives the same answers in
676
+ * O(n). The per-message function stays for its own callers and for the cases
677
+ * this one cannot serve — a message that is not in the list it was handed.
678
+ *
679
+ * Keyed by message id. A message with no id is skipped rather than colliding
680
+ * with another one under `undefined`.
681
+ */
682
+ export function turnTimings(
683
+ messages: ReadonlyArray<{ id: string; role: string; createdAt?: string }>,
684
+ ): Map<string, TurnTiming> {
685
+ const timings = new Map<string, TurnTiming>();
686
+ /** The stamp of the last user message SEEN, which is the send that opened the turn each row below belongs to. */
687
+ let sendStamp: number | null = null;
688
+
689
+ for (const message of messages) {
690
+ const ownStamp = stampToMs(message.createdAt);
691
+
692
+ // Read before writing: a user message's own turn is bracketed by the send
693
+ // BEFORE it, never by itself. The original walked backwards from the row
694
+ // and broke on the first user message, which is the same message this
695
+ // carries forward.
696
+ if (message.id !== undefined) {
697
+ timings.set(message.id, {
698
+ startedAt: sendStamp ?? ownStamp,
699
+ endedAt:
700
+ sendStamp !== null &&
701
+ ownStamp !== null &&
702
+ ownStamp - sendStamp >= MIN_PERSISTED_ELAPSED_MS
703
+ ? ownStamp
704
+ : null,
705
+ });
706
+ }
707
+
708
+ if (message.role === 'user') sendStamp = ownStamp;
709
+ }
710
+
711
+ return timings;
712
+ }
713
+
714
+ export function turnTiming(
715
+ message: { id: string; createdAt?: string },
716
+ messages: ReadonlyArray<{ id: string; role: string; createdAt?: string }>,
717
+ ): TurnTiming {
718
+ const index = messages.findIndex((m) => m.id === message.id);
719
+ let sendStamp: number | null = null;
720
+ for (let i = index - 1; i >= 0; i -= 1) {
721
+ if (messages[i].role === 'user') {
722
+ sendStamp = stampToMs(messages[i].createdAt);
723
+ break;
724
+ }
725
+ }
726
+ const ownStamp = stampToMs(message.createdAt);
727
+ const startedAt = sendStamp ?? ownStamp;
728
+ const endedAt =
729
+ sendStamp !== null && ownStamp !== null && ownStamp - sendStamp >= MIN_PERSISTED_ELAPSED_MS
730
+ ? ownStamp
731
+ : null;
732
+ return { startedAt, endedAt };
733
+ }
734
+
735
+ /**
736
+ * An elapsed time the way the summary row prints it: `10s`, `1m 5s`, `1h 2m`.
737
+ * `long` spells the units out for an accessible name, where "10s" is read as
738
+ * a letter. Rounded to the second; anything under one reads as `0s`.
739
+ */
740
+ export function formatElapsed(ms: number, long = false): string {
741
+ const total = Math.max(0, Math.round(ms / 1000));
742
+ const hours = Math.floor(total / 3600);
743
+ const minutes = Math.floor((total % 3600) / 60);
744
+ const seconds = total % 60;
745
+ const unit = (n: number, short: string, one: string, many: string): string =>
746
+ long ? `${n} ${n === 1 ? one : many}` : `${n}${short}`;
747
+ if (hours > 0)
748
+ return `${unit(hours, 'h', 'hour', 'hours')} ${unit(minutes, 'm', 'minute', 'minutes')}`;
749
+ if (minutes > 0)
750
+ return `${unit(minutes, 'm', 'minute', 'minutes')} ${unit(seconds, 's', 'second', 'seconds')}`;
751
+ return unit(seconds, 's', 'second', 'seconds');
752
+ }