@alia.onl/sdk 15.8.0 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +262 -13
  2. package/README.md +223 -151
  3. package/package.json +16 -6
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +4 -4
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +98 -35
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +34 -15
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +141 -0
  91. package/src/runtime/app-assertion.ts +53 -0
  92. package/src/runtime/attachment-utils.ts +96 -0
  93. package/src/runtime/composer-draft-store.ts +270 -0
  94. package/src/runtime/config.tsx +186 -0
  95. package/src/runtime/conversation-store.ts +49 -0
  96. package/src/runtime/conversations.ts +557 -0
  97. package/src/runtime/error-utils.ts +104 -0
  98. package/src/runtime/host-tools.ts +140 -0
  99. package/src/runtime/index.ts +113 -0
  100. package/src/runtime/message-history.ts +124 -0
  101. package/src/runtime/model.ts +39 -0
  102. package/src/runtime/oxy-files.ts +37 -0
  103. package/src/runtime/power-levels.ts +66 -0
  104. package/src/runtime/query-keys.ts +34 -0
  105. package/src/runtime/random-uuid.ts +25 -0
  106. package/src/runtime/routes.ts +51 -0
  107. package/src/runtime/sharing.ts +165 -0
  108. package/src/runtime/sse-frame-reader.ts +88 -0
  109. package/src/runtime/stream-events.ts +146 -0
  110. package/src/runtime/stream-outcome.ts +21 -0
  111. package/src/runtime/thought-store.ts +103 -0
  112. package/src/runtime/thread/agent-activity.ts +261 -0
  113. package/src/runtime/thread/automation-labels.ts +79 -0
  114. package/src/runtime/thread/conversation-export.ts +135 -0
  115. package/src/runtime/thread/message-days.ts +120 -0
  116. package/src/runtime/thread/message-processor.ts +93 -0
  117. package/src/runtime/thread/task-utils.ts +11 -0
  118. package/src/runtime/thread/thought-utils.ts +752 -0
  119. package/src/runtime/thread/thread-history.ts +139 -0
  120. package/src/runtime/thread/timeline.ts +202 -0
  121. package/src/runtime/thread/title-tags.ts +8 -0
  122. package/src/runtime/thread/tool-cards.ts +383 -0
  123. package/src/runtime/turn-failure.ts +108 -0
  124. package/src/runtime/types.ts +148 -0
  125. package/src/runtime/usage-limit-error.ts +44 -0
  126. package/src/runtime/use-agent-activity.ts +80 -0
  127. package/src/runtime/use-conversation-stream.ts +1433 -0
  128. package/src/runtime/use-conversation-thread.ts +556 -0
  129. package/src/runtime/use-start-conversation.ts +107 -0
  130. package/src/runtime/use-thread-history.ts +64 -0
  131. package/src/runtime/use-thread-search.ts +95 -0
  132. package/src/runtime/use-turn-edit.ts +99 -0
  133. package/src/tools.ts +2 -2
  134. package/src/types.ts +18 -47
  135. package/src/components/AliaChatMessageList.tsx +0 -532
  136. package/src/components/ThinkingBlock.tsx +0 -43
  137. package/src/hooks/useAliaChat.ts +0 -573
  138. package/src/lib/catalogue.ts +0 -236
  139. package/src/lib/chat-stream.ts +0 -655
  140. package/src/lib/chat-transport.ts +0 -98
  141. package/src/lib/delegation.ts +0 -34
  142. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,261 @@
1
+ /**
2
+ * What an agent's run is doing, as data: the events it emits, and the state they add up to.
3
+ *
4
+ * Pure. `use-agent-activity.ts` subscribes to the run and feeds `applyActivityEvent` with what
5
+ * arrives; `hydrateActivity` seeds the state from what the run had already done when the screen
6
+ * opened. Both the live card in the thread and a task's timeline read the result.
7
+ */
8
+
9
+ export interface PlanItem {
10
+ id: number;
11
+ text: string;
12
+ status: string;
13
+ }
14
+
15
+ export interface PlanProgress {
16
+ items: PlanItem[];
17
+ completed: number;
18
+ total: number;
19
+ }
20
+
21
+ export type AgentActivityEventType =
22
+ | 'system'
23
+ | 'thinking'
24
+ | 'response'
25
+ | 'tool_call'
26
+ | 'tool_result'
27
+ | 'error'
28
+ | 'complete'
29
+ | 'screenshot'
30
+ | 'plan_progress'
31
+ | 'file_change'
32
+ | 'source_found'
33
+ | 'threat';
34
+
35
+ export interface AgentActivityEvent {
36
+ type: AgentActivityEventType;
37
+ content: string;
38
+ timestamp: number;
39
+ sessionId: string;
40
+ agentId?: string;
41
+ metadata?: {
42
+ toolName?: string;
43
+ /** What the tool was called with; the browser tool's `action`, `url` and `query`, a plan's `action`, a delegate's `agent`. */
44
+ args?: {
45
+ action?: string;
46
+ url?: string;
47
+ query?: string;
48
+ agent?: string;
49
+ [key: string]: unknown;
50
+ };
51
+ duration?: number;
52
+ url?: string;
53
+ title?: string;
54
+ domain?: string;
55
+ };
56
+ data?: {
57
+ url?: string;
58
+ plan?: PlanProgress;
59
+ currentStep?: number;
60
+ maxSteps?: number;
61
+ };
62
+ }
63
+
64
+ export interface AgentSource {
65
+ url: string;
66
+ title: string;
67
+ domain: string;
68
+ snippet: string;
69
+ timestamp: number;
70
+ }
71
+
72
+ export interface AgentActivityState {
73
+ /** Current plan with checklist items */
74
+ plan: PlanProgress | null;
75
+ /** Current action being executed */
76
+ currentAction: { toolName: string; content: string } | null;
77
+ /** Whether the agent has completed */
78
+ isComplete: boolean;
79
+ /** Whether there's an error */
80
+ hasError: boolean;
81
+ /** Last error message */
82
+ lastError: string | null;
83
+ /** Total events received */
84
+ eventCount: number;
85
+ /** All activity events (last 50) */
86
+ events: AgentActivityEvent[];
87
+ /** Start time of first event */
88
+ startedAt: number | null;
89
+ /** Sources found during browsing */
90
+ sources: AgentSource[];
91
+ /** Latest text response from agent */
92
+ latestResponse: string | null;
93
+ /** Total credits charged for this session (present once the run settles) */
94
+ creditsCharged?: number;
95
+ }
96
+
97
+ export const INITIAL_ACTIVITY: AgentActivityState = {
98
+ plan: null,
99
+ currentAction: null,
100
+ isComplete: false,
101
+ hasError: false,
102
+ lastError: null,
103
+ eventCount: 0,
104
+ events: [],
105
+ startedAt: null,
106
+ sources: [],
107
+ latestResponse: null,
108
+ };
109
+
110
+ /** How many events the state keeps. */
111
+ export const MAX_ACTIVITY_EVENTS = 50;
112
+
113
+ /** Add one event to the run's state. */
114
+ export function applyActivityEvent(
115
+ prev: AgentActivityState,
116
+ event: AgentActivityEvent,
117
+ ): AgentActivityState {
118
+ const next: AgentActivityState = {
119
+ ...prev,
120
+ eventCount: prev.eventCount + 1,
121
+ events: [...prev.events.slice(-(MAX_ACTIVITY_EVENTS - 1)), event],
122
+ startedAt: prev.startedAt ?? event.timestamp,
123
+ };
124
+
125
+ switch (event.type) {
126
+ case 'plan_progress':
127
+ if (event.data?.plan) next.plan = event.data.plan;
128
+ break;
129
+
130
+ case 'tool_call':
131
+ next.currentAction = {
132
+ toolName: event.metadata?.toolName || 'action',
133
+ content: event.content,
134
+ };
135
+ break;
136
+
137
+ case 'tool_result':
138
+ next.currentAction = null;
139
+ break;
140
+
141
+ case 'error':
142
+ next.hasError = true;
143
+ next.lastError = event.content;
144
+ next.currentAction = null;
145
+ break;
146
+
147
+ case 'complete':
148
+ next.isComplete = true;
149
+ next.currentAction = null;
150
+ next.latestResponse = null;
151
+ break;
152
+
153
+ case 'source_found': {
154
+ const url = event.metadata?.url;
155
+ if (url && !prev.sources.some((source) => source.url === url)) {
156
+ next.sources = [
157
+ ...prev.sources,
158
+ {
159
+ url,
160
+ title: event.metadata?.title || '',
161
+ domain: event.metadata?.domain || new URL(url).hostname,
162
+ snippet: event.content?.slice(0, 200) || '',
163
+ timestamp: event.timestamp,
164
+ },
165
+ ];
166
+ }
167
+ break;
168
+ }
169
+
170
+ case 'response':
171
+ next.latestResponse = event.content;
172
+ break;
173
+ }
174
+
175
+ return next;
176
+ }
177
+
178
+ /** An event as the session-status route stores it. */
179
+ interface PersistedAgentEvent {
180
+ type: string;
181
+ content: string;
182
+ timestamp: number;
183
+ metadata?: AgentActivityEvent['metadata'];
184
+ }
185
+
186
+ /** What `GET` on a session's status answers. */
187
+ export interface AgentSessionStatus {
188
+ recentEvents?: PersistedAgentEvent[];
189
+ session?: {
190
+ plan?: { items?: PlanItem[] };
191
+ status?: string;
192
+ result?: string;
193
+ stats?: { creditsCharged?: number };
194
+ };
195
+ }
196
+
197
+ function eventTypeOf(type: string): AgentActivityEventType {
198
+ switch (type) {
199
+ case 'action':
200
+ return 'tool_call';
201
+ case 'observation':
202
+ return 'tool_result';
203
+ case 'threat_detected':
204
+ return 'threat';
205
+ case 'thinking':
206
+ case 'response':
207
+ case 'error':
208
+ case 'complete':
209
+ case 'screenshot':
210
+ case 'plan_progress':
211
+ case 'file_change':
212
+ case 'source_found':
213
+ return type;
214
+ // `user_message`, `system_message`, `plan_update` and anything newer.
215
+ default:
216
+ return 'system';
217
+ }
218
+ }
219
+
220
+ /** Seed the state with what the run had done before this screen subscribed to it. */
221
+ export function hydrateActivity(
222
+ prev: AgentActivityState,
223
+ status: AgentSessionStatus,
224
+ sessionId: string,
225
+ ): AgentActivityState {
226
+ const events = (Array.isArray(status.recentEvents) ? status.recentEvents : [])
227
+ .map(
228
+ (entry): AgentActivityEvent => ({
229
+ type: eventTypeOf(entry.type),
230
+ content: entry.content,
231
+ timestamp: entry.timestamp,
232
+ sessionId,
233
+ metadata: entry.metadata,
234
+ }),
235
+ )
236
+ .slice(-MAX_ACTIVITY_EVENTS);
237
+ const session = status.session;
238
+ const planItems = Array.isArray(session?.plan?.items) ? session.plan.items : [];
239
+ const failed = session?.status === 'failed';
240
+ const credits = session?.stats?.creditsCharged;
241
+
242
+ return {
243
+ ...prev,
244
+ events,
245
+ eventCount: events.length,
246
+ startedAt: events[0]?.timestamp || prev.startedAt,
247
+ plan:
248
+ planItems.length > 0
249
+ ? {
250
+ items: planItems,
251
+ completed: planItems.filter((item) => item.status === 'completed').length,
252
+ total: planItems.length,
253
+ }
254
+ : prev.plan,
255
+ isComplete: session?.status === 'completed' || session?.status === 'cancelled',
256
+ hasError: failed,
257
+ lastError: failed ? session?.result || prev.lastError : prev.lastError,
258
+ latestResponse: session?.result || prev.latestResponse,
259
+ creditsCharged: typeof credits === 'number' ? credits : prev.creditsCharged,
260
+ };
261
+ }
@@ -0,0 +1,79 @@
1
+ import type { Translate } from '../../lib/copy';
2
+ import type { AutomationTrigger } from '../types';
3
+
4
+ /**
5
+ * A scheduled task's trigger as words.
6
+ *
7
+ * Drawn by the thread's scheduled-task card, and by every host that lists the person's tasks, so
8
+ * the words live beside the type rather than in either. Pure: the translator is a parameter.
9
+ */
10
+
11
+ function cronDayList(field: string): number[] | null {
12
+ const days: number[] = [];
13
+ for (const part of field.split(',')) {
14
+ const range = part.match(/^(\d)(?:-(\d))?$/);
15
+ if (!range) return null;
16
+ const from = Number(range[1]);
17
+ const to = range[2] === undefined ? from : Number(range[2]);
18
+ if (from > 6 || to > 6 || to < from) return null;
19
+ for (let day = from; day <= to; day += 1) days.push(day);
20
+ }
21
+ // Cron accepts 7 for Sunday in some dialects; the scheduler here writes 0.
22
+ const unique = [...new Set(days)].sort((a, b) => a - b);
23
+ return unique.length > 0 ? unique : null;
24
+ }
25
+
26
+ /**
27
+ * A cron expression as a sentence, for the shapes the app itself writes.
28
+ *
29
+ * `scheduleToCron` on the API produces exactly three forms from the create
30
+ * dialog — `*\/N * * * *` for an interval, `M H * * *` for every day and
31
+ * `M H * * 1,3,5` for chosen days — and those are what a person expects to
32
+ * read back as "Every hour" or "Mondays at 09:00", not as five fields (#537).
33
+ * Anything else (a hand-written structured schedule) is returned verbatim
34
+ * rather than guessed at: a wrong sentence is worse than a cron string.
35
+ */
36
+ export function cronLabel(cron: string, t: Translate): string {
37
+ const fields = cron.trim().split(/\s+/);
38
+ if (fields.length !== 5) return cron;
39
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [
40
+ string,
41
+ string,
42
+ string,
43
+ string,
44
+ string,
45
+ ];
46
+ if (dayOfMonth !== '*' || month !== '*') return cron;
47
+
48
+ const interval = minute.match(/^\*\/(\d+)$/);
49
+ if (interval && hour === '*' && dayOfWeek === '*') {
50
+ const minutes = Number(interval[1]);
51
+ if (minutes === 60) return t('automations.cron.everyHour');
52
+ if (minutes > 60 && minutes % 60 === 0)
53
+ return t('automations.cron.everyHours', { count: minutes / 60 });
54
+ return t('automations.cron.everyMinutes', { count: minutes });
55
+ }
56
+
57
+ if (!/^\d{1,2}$/.test(minute) || !/^\d{1,2}$/.test(hour)) return cron;
58
+ if (Number(minute) > 59 || Number(hour) > 23) return cron;
59
+ const time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
60
+ if (dayOfWeek === '*') return t('automations.cron.dailyAt', { time });
61
+ const days = cronDayList(dayOfWeek);
62
+ if (!days) return cron;
63
+ if (days.length === 7) return t('automations.cron.dailyAt', { time });
64
+ if (days.length === 5 && days.every((day, index) => day === index + 1)) {
65
+ return t('automations.cron.weekdaysAt', { time });
66
+ }
67
+ if (days.length === 2 && days[0] === 0 && days[1] === 6)
68
+ return t('automations.cron.weekendsAt', { time });
69
+ const names = days.map((day) => t(`automations.cron.dayPlural.${day}`));
70
+ return t('automations.cron.daysAt', { days: names.join(', '), time });
71
+ }
72
+
73
+ export function triggerLabel(trigger: AutomationTrigger, t: Translate): string {
74
+ if (trigger.type === 'manual') return t('automations.trigger.manual');
75
+ if (trigger.type === 'event') {
76
+ return `${trigger.appId ?? t('automations.trigger.anyApp')} · ${trigger.eventType ?? t('automations.trigger.anyEvent')}`;
77
+ }
78
+ return `${trigger.cron ? cronLabel(trigger.cron, t) : t('automations.trigger.unscheduled')} · ${trigger.timezone ?? 'UTC'}`;
79
+ }
@@ -0,0 +1,135 @@
1
+ import type { Message } from '../types';
2
+ import { extractSources } from './thought-utils';
3
+
4
+ /**
5
+ * A conversation as a Markdown document.
6
+ *
7
+ * Pure, and deliberately so: the header's menu item is the only thing that
8
+ * asks for this, and what it hands over is the same `messages` array the
9
+ * screen is streaming into. Everything about DELIVERY — a download on web, the
10
+ * share sheet on a phone — lives in `conversation-share.ts`, so this module can
11
+ * be tested against a string and nothing else.
12
+ *
13
+ * ## What is kept and what is left out
14
+ *
15
+ * - Roles, as headings. The person is `## You` (or the translation handed in)
16
+ * and the answer is `## Alia`, or the agent's own name when the thread is
17
+ * with one, or the delegated agent's name on a turn that came from one.
18
+ * - The text, verbatim. An answer is already Markdown — fenced code blocks,
19
+ * lists, tables — so it is pasted as it is rather than re-rendered, which is
20
+ * what makes a code block survive the round trip.
21
+ * - Images, as image links. A multi-part user turn carries them as
22
+ * `image_url` parts, and Markdown has a spelling for exactly that.
23
+ * - Sources, as a list of links under the answer that used them. They are
24
+ * read out of `toolInvocations` — the same jsonb the message is stored with
25
+ * — by the same `extractSources` the thread shows them from, so the export
26
+ * cites what the screen cites.
27
+ * - Thinking is OMITTED. It is the model's scratchpad, shown in a side panel
28
+ * on request; a document of the conversation is a document of what was
29
+ * said.
30
+ * - `system` turns are omitted too: they are not part of the exchange.
31
+ */
32
+ export interface ConversationExportOptions {
33
+ /** The conversation's title, as the document's heading. */
34
+ title: string;
35
+ messages: readonly Message[];
36
+ /** When the document was made — the header states it. */
37
+ exportedAt: Date;
38
+ /** The name Alia answers under here; the heading for assistant turns. */
39
+ assistantName?: string;
40
+ /** The heading for the person's own turns, in their language. */
41
+ userLabel?: string;
42
+ }
43
+
44
+ const DEFAULT_ASSISTANT = 'Alia';
45
+ const DEFAULT_USER = 'You';
46
+
47
+ /** `YYYY-MM-DD` in the reader's own timezone, not the UTC one `toISOString` gives. */
48
+ export function localDate(date: Date): string {
49
+ const pad = (n: number) => String(n).padStart(2, '0');
50
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
51
+ }
52
+
53
+ /**
54
+ * The body of one message as Markdown: its text, then its images.
55
+ *
56
+ * A part the exporter does not know is dropped rather than stringified — an
57
+ * unknown part in a saved message is a shape from a later version of the app,
58
+ * and `[object Object]` in the middle of a document helps nobody.
59
+ */
60
+ function messageBody(content: Message['content']): string {
61
+ if (typeof content === 'string') return content.trim();
62
+ if (!Array.isArray(content)) return '';
63
+
64
+ const text: string[] = [];
65
+ const images: string[] = [];
66
+ for (const part of content) {
67
+ if (part.type === 'text' && typeof part.text === 'string') {
68
+ text.push(part.text);
69
+ } else if (part.type === 'image_url') {
70
+ const url = (part.image_url as { url?: unknown } | undefined)?.url;
71
+ if (typeof url === 'string' && url !== '') images.push(url);
72
+ }
73
+ }
74
+
75
+ const lines = [text.join('').trim()];
76
+ images.forEach((url, i) => lines.push(`![Image ${i + 1}](${url})`));
77
+ return lines.filter((line) => line !== '').join('\n\n');
78
+ }
79
+
80
+ /** The heading a turn is written under. */
81
+ function heading(message: Message, assistantName: string, userLabel: string): string {
82
+ if (message.role === 'user') return userLabel;
83
+ return message.agentInfo?.name || assistantName;
84
+ }
85
+
86
+ export function buildConversationMarkdown({
87
+ title,
88
+ messages,
89
+ exportedAt,
90
+ assistantName = DEFAULT_ASSISTANT,
91
+ userLabel = DEFAULT_USER,
92
+ }: ConversationExportOptions): string {
93
+ const sections: string[] = [
94
+ `# ${title.trim() || DEFAULT_ASSISTANT}`,
95
+ `_Exported ${localDate(exportedAt)}_`,
96
+ ];
97
+
98
+ for (const message of messages) {
99
+ if (message.role !== 'user' && message.role !== 'assistant') continue;
100
+
101
+ const body = messageBody(message.content);
102
+ const sources = message.role === 'assistant' ? extractSources(message.toolInvocations) : [];
103
+ // An assistant placeholder that never got its answer, or a turn whose
104
+ // only content was a part the exporter dropped: nothing to write under
105
+ // the heading, so no heading.
106
+ if (body === '' && sources.length === 0) continue;
107
+
108
+ const parts = [`## ${heading(message, assistantName, userLabel)}`];
109
+ if (body !== '') parts.push(body);
110
+ if (sources.length > 0) {
111
+ parts.push(['### Sources', ...sources.map((s) => `- [${s.title}](${s.url})`)].join('\n'));
112
+ }
113
+ sections.push(parts.join('\n\n'));
114
+ }
115
+
116
+ return `${sections.join('\n\n')}\n`;
117
+ }
118
+
119
+ /**
120
+ * A file name for the document: the title, made safe for a file system, with
121
+ * the date so two exports of one thread do not overwrite each other.
122
+ */
123
+ export function exportFilename(title: string, exportedAt: Date): string {
124
+ // NFD splits an accented letter into letter + combining mark, and the range
125
+ // is the combining marks block, spelled as a range and not as `\p{M}`: a
126
+ // property escape throws at RUNTIME on Hermes, where this code runs.
127
+ const slug = title
128
+ .toLowerCase()
129
+ .normalize('NFD')
130
+ .replace(/[\u0300-\u036f]/g, '')
131
+ .replace(/[^a-z0-9]+/g, '-')
132
+ .replace(/^-+|-+$/g, '')
133
+ .slice(0, 60);
134
+ return `${slug || 'conversation'}-${localDate(exportedAt)}.md`;
135
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Where a thread changes day, and which day it changed to.
3
+ *
4
+ * This is the DERIVED boundary, and the only one drawn from a timestamp: the
5
+ * clock passed midnight, and nobody decided it. A thread's other boundary — "a
6
+ * new conversation starts here" — is a seam between two conversations, read
7
+ * from which one each message belongs to, so it is not computed here and never
8
+ * from elapsed time. "N hours passed, draw a line" lies the day somebody comes
9
+ * back a week later to continue the same idea.
10
+ *
11
+ * ## The timezone is the client's, and that is the whole difficulty
12
+ *
13
+ * `createdAt` arrives as an ISO instant, which names no day: 23:40 on the 4th
14
+ * in Madrid and 22:40 on the 4th in London are the same instant, and 12:00 UTC
15
+ * is the 1st in Niue and the 2nd in Kiritimati. A separator computed from the
16
+ * UTC calendar is therefore wrong for most of the planet for part of every day,
17
+ * and wrong in the way nobody reports: the line lands an hour off, or a
18
+ * conversation that ran through local midnight shows none at all.
19
+ *
20
+ * So the day of an instant is read through `Date`'s LOCAL accessors, which is
21
+ * the device's own timezone — the one the person reading the thread is in.
22
+ *
23
+ * ## A message with no timestamp draws no line
24
+ *
25
+ * Everything the server sends carries `createdAt`; a message the client has
26
+ * only just appended may not, if it has not made the round trip. Those are
27
+ * skipped rather than assumed to be today, because assuming would draw a line
28
+ * that a reload then removes — and a separator that appears and disappears is
29
+ * worse than one that arrives a moment late.
30
+ */
31
+
32
+ /** What a separator says. `today` and `yesterday` are named so the caller can translate them. */
33
+ export type DaySeparatorLabel =
34
+ | { readonly kind: 'today' }
35
+ | { readonly kind: 'yesterday' }
36
+ | { readonly kind: 'date'; readonly text: string };
37
+
38
+ export interface DaySeparator {
39
+ /** The id of the message this separator sits directly above. */
40
+ readonly messageId: string;
41
+ readonly label: DaySeparatorLabel;
42
+ }
43
+
44
+ /** The two fields a separator is derived from. */
45
+ interface DatedMessage {
46
+ readonly id: string;
47
+ readonly createdAt?: string;
48
+ }
49
+
50
+ /** The local calendar day of an instant, as a key that compares equal only within one day. */
51
+ function localDayKey(date: Date): string {
52
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
53
+ }
54
+
55
+ /** The instant a value names, or `null` if it names none. */
56
+ function instant(value: string | undefined): Date | null {
57
+ if (value === undefined) return null;
58
+ const date = new Date(value);
59
+ return Number.isNaN(date.getTime()) ? null : date;
60
+ }
61
+
62
+ /**
63
+ * How many local days back `date` is from `now`.
64
+ *
65
+ * Counted between local CALENDAR days rather than by subtracting milliseconds:
66
+ * a day is not 24 hours on the two days a year a timezone shifts, and an hour
67
+ * of error on those days would relabel "yesterday" as a date.
68
+ */
69
+ function daysBefore(date: Date, now: Date): number {
70
+ const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
71
+ const startOfNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
72
+ return Math.round((startOfNow.getTime() - startOfDate.getTime()) / 86_400_000);
73
+ }
74
+
75
+ function labelFor(date: Date, now: Date, locale: string): DaySeparatorLabel {
76
+ const back = daysBefore(date, now);
77
+ if (back === 0) return { kind: 'today' };
78
+ if (back === 1) return { kind: 'yesterday' };
79
+ return {
80
+ kind: 'date',
81
+ text: new Intl.DateTimeFormat(locale, {
82
+ weekday: 'short',
83
+ day: 'numeric',
84
+ month: 'short',
85
+ // Only when it is not this year. A year on every line is noise; a year on
86
+ // none of them is a thread that silently spans two.
87
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
88
+ }).format(date),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The separators a message list needs, in order.
94
+ *
95
+ * One per CHANGE of local day, so two messages on the same day produce none and
96
+ * a pair that straddles local midnight produces one. There is no separator above
97
+ * the first message: it would say "today" on top of every conversation anyone is
98
+ * currently having, which is a label for a boundary that is not there.
99
+ */
100
+ export function daySeparators(
101
+ messages: readonly DatedMessage[],
102
+ now: Date,
103
+ locale: string,
104
+ ): DaySeparator[] {
105
+ const separators: DaySeparator[] = [];
106
+ let previousKey: string | null = null;
107
+
108
+ for (const message of messages) {
109
+ const date = instant(message.createdAt);
110
+ if (date === null) continue;
111
+
112
+ const key = localDayKey(date);
113
+ if (previousKey !== null && key !== previousKey) {
114
+ separators.push({ messageId: message.id, label: labelFor(date, now, locale) });
115
+ }
116
+ previousKey = key;
117
+ }
118
+
119
+ return separators;
120
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Message Processor - Platform-specific message processing
3
+ *
4
+ * This module handles platform-specific processing of AI responses.
5
+ * Different platforms (Web/Mobile App vs Telegram) have different capabilities,
6
+ * so we process messages differently for each.
7
+ */
8
+
9
+ import { TITLE_STRIP_RE } from './title-tags';
10
+
11
+ export type Platform = 'app' | 'telegram';
12
+
13
+ export interface ProcessedMessage {
14
+ text: string;
15
+ components?: any[]; // Visual components for the app (COMPACTLIST, BANNER, etc.)
16
+ metadata?: Record<string, any>;
17
+ }
18
+
19
+ /**
20
+ * Process a message for the Web/Mobile App
21
+ * - Removes Telegram-specific tags ([REACT], [TGIMAGE], [TGLINKS], [TGDOC])
22
+ * - Preserves app-specific components ([COMPACTLIST], [BANNER], [COMPARISON], etc.)
23
+ */
24
+ function processForApp(content: string): ProcessedMessage {
25
+ // Remove Telegram-specific tags and title tags
26
+ const cleanedText = content
27
+ .replace(/\[(?:ALIA_)?REACT:[^\]]+\]\s*/g, '')
28
+ .replace(/\[(?:ALIA_)?TGIMAGE[^\]]*\]\s*/g, '')
29
+ .replace(/\[(?:ALIA_)?TGLINKS[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TGLINKS\]\s*/g, '')
30
+ .replace(/\[(?:ALIA_)?TGDOC[^\]]*\]\s*/g, '')
31
+ .replace(TITLE_STRIP_RE, '')
32
+ .trim();
33
+
34
+ // App-specific components like [COMPACTLIST], [BANNER], etc. are kept
35
+ // They will be rendered by the Markdown component
36
+ return {
37
+ text: cleanedText,
38
+ components: [], // Could extract components here if needed
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Process a message for Telegram
44
+ * - Removes app-specific components ([COMPACTLIST], [BANNER], etc.)
45
+ * - Preserves Telegram-specific tags for bot processing
46
+ */
47
+ function processForTelegram(content: string): ProcessedMessage {
48
+ // Remove app-specific visual components (not supported in Telegram)
49
+ const cleanedText = content
50
+ .replace(/\[(?:ALIA_)?COMPACTLIST[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPACTLIST\]\s*/g, '')
51
+ .replace(/\[(?:ALIA_)?BANNER[^\]]*\][\s\S]*?\[\/(?:ALIA_)?BANNER\]\s*/g, '')
52
+ .replace(/\[(?:ALIA_)?COMPARISON[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPARISON\]\s*/g, '')
53
+ .replace(/\[(?:ALIA_)?TIMELINE[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TIMELINE\]\s*/g, '')
54
+ .replace(/\[(?:ALIA_)?IMAGE[^\]]*\]/g, '')
55
+ .replace(/\[(?:ALIA_)?CREDIBILITY[^\]]*\]/g, '')
56
+ .replace(TITLE_STRIP_RE, '')
57
+ .trim();
58
+
59
+ // Telegram tags like [REACT], [TGIMAGE], etc. are kept
60
+ // They will be processed by the Telegram bot
61
+ return {
62
+ text: cleanedText,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Main message processor - Routes to platform-specific processor
68
+ */
69
+ export function processMessage(content: string, platform: Platform): ProcessedMessage {
70
+ switch (platform) {
71
+ case 'app':
72
+ return processForApp(content);
73
+ case 'telegram':
74
+ return processForTelegram(content);
75
+ default:
76
+ // Fallback: remove all special tags
77
+ return {
78
+ text: content
79
+ .replace(/\[(?:ALIA_)?REACT:[^\]]+\]\s*/g, '')
80
+ .replace(/\[(?:ALIA_)?TGIMAGE[^\]]*\]\s*/g, '')
81
+ .replace(/\[(?:ALIA_)?TGLINKS[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TGLINKS\]\s*/g, '')
82
+ .replace(/\[(?:ALIA_)?TGDOC[^\]]*\]\s*/g, '')
83
+ .replace(/\[(?:ALIA_)?COMPACTLIST[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPACTLIST\]\s*/g, '')
84
+ .replace(/\[(?:ALIA_)?BANNER[^\]]*\][\s\S]*?\[\/(?:ALIA_)?BANNER\]\s*/g, '')
85
+ .replace(/\[(?:ALIA_)?COMPARISON[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPARISON\]\s*/g, '')
86
+ .replace(/\[(?:ALIA_)?TIMELINE[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TIMELINE\]\s*/g, '')
87
+ .replace(/\[(?:ALIA_)?IMAGE[^\]]*\]/g, '')
88
+ .replace(/\[(?:ALIA_)?CREDIBILITY[^\]]*\]/g, '')
89
+ .replace(TITLE_STRIP_RE, '')
90
+ .trim(),
91
+ };
92
+ }
93
+ }