@arkimedehq/recordare-client 0.1.0 → 0.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/src/client.ts CHANGED
@@ -3,7 +3,8 @@
3
3
 
4
4
  import {
5
5
  CONVERSATION_HEADER, type Digest, type Episode, type EpisodeDetail, type EpisodeQuery, type Fact, type IngestRequest, type IngestResult,
6
- MAX_MESSAGES_PER_REQUEST, type Me, type MeSettings, type MemoryContext, type Note, type PlanStatus, type Precision,
6
+ type LearnSource, type LearnSourceResult, MAX_MESSAGES_PER_REQUEST, type Me, type MeSettings, type MemoryContext, type Note, type PlanStatus,
7
+ type Precision, type Source, SOURCE_PART_BYTES,
7
8
  } from './contract.js';
8
9
  import { MemoryNotEmptyError, RecordareHttpError } from './errors.js';
9
10
  import { type ClientOptions, Http } from './http.js';
@@ -20,7 +21,7 @@ function qs(query: object): string {
20
21
  }
21
22
 
22
23
  /**
23
- * Recordare for one platform (docs/INTEGRATION.md): who a user is and their consent, their settings, the conversations
24
+ * Recordare for one platform (docs/INTEGRATION.md): who a user is, their settings, the conversations
24
25
  * the platform sends, deletions, and recall over MCP. Stateless apart from MCP sessions; a host keeps its own outbox and
25
26
  * cache (see `delivery` and `PersonDirectory`).
26
27
  */
@@ -33,12 +34,12 @@ export class RecordareClient {
33
34
  this.mcp = new RecordareMcp(this.http, options.mcp);
34
35
  }
35
36
 
36
- /** The person behind `user` (auto-provisioned when the client allows it), their consent and kind of memory. */
37
+ /** The memory behind `user` (auto-provisioned when the client allows it), its mode and gender. */
37
38
  me(user: string): Promise<Me> {
38
39
  return this.http.request<Me>('GET', 'api/v1/me', { user });
39
40
  }
40
41
 
41
- /** The person's settings from the platform: the name (keep it in sync on every rename) and the kind of memory. */
42
+ /** The memory's settings from the platform: the name (keep it in sync on every rename), the mode and the gender. */
42
43
  async updateMe(user: string, settings: MeSettings): Promise<void> {
43
44
  try {
44
45
  await this.http.request('PATCH', 'api/v1/me', { user, body: settings });
@@ -50,10 +51,10 @@ export class RecordareClient {
50
51
 
51
52
  /**
52
53
  * Sends messages of one conversation, split into requests of at most MAX_MESSAGES_PER_REQUEST. Idempotent: Recordare
53
- * deduplicates on each message's externalId. Before consent nothing is stored (`stored: false`).
54
+ * deduplicates on each message's externalId. Recordare always stores (D50): the on/off switch is the platform's.
54
55
  */
55
56
  async ingest(user: string, req: IngestRequest): Promise<IngestResult> {
56
- const total: IngestResult = { conversationId: null, accepted: 0, duplicates: 0, conflicts: [], stored: true };
57
+ const total: IngestResult = { conversationId: null, accepted: 0, duplicates: 0, conflicts: [] };
57
58
  for (let i = 0; i < req.messages.length; i += MAX_MESSAGES_PER_REQUEST) {
58
59
  const last = i + MAX_MESSAGES_PER_REQUEST >= req.messages.length;
59
60
  const part = await this.http.request<IngestResult>('POST', 'api/v1/ingest/messages', {
@@ -69,7 +70,6 @@ export class RecordareClient {
69
70
  total.accepted += part.accepted;
70
71
  total.duplicates += part.duplicates;
71
72
  total.conflicts.push(...part.conflicts);
72
- total.stored &&= part.stored;
73
73
  }
74
74
  return total;
75
75
  }
@@ -83,6 +83,19 @@ export class RecordareClient {
83
83
  return this.http.request<MemoryContext>('POST', 'api/v1/context', { user, body: { query }, headers: { [CONVERSATION_HEADER]: conversation } });
84
84
  }
85
85
 
86
+ /**
87
+ * Stores the turn and returns the memory context for it in one round trip (needs the `ingest` and `read` scopes):
88
+ * the context is computed for the ingested conversation, from `query` or else the turn's last user message.
89
+ */
90
+ contextWithTurn(user: string, turn: IngestRequest, query?: string): Promise<MemoryContext> {
91
+ return this.http.request<MemoryContext>('POST', 'api/v1/context', { user, body: { ingest: turn, ...(query ? { query } : {}) } });
92
+ }
93
+
94
+ /** The conversation ended (session closed, /new): extraction runs now. A conversation Recordare never had is done. */
95
+ async endConversation(user: string, conversation: string): Promise<void> {
96
+ await this.gone(this.http.request('POST', `api/v1/ingest/conversations/${enc(conversation)}/end`, { user }));
97
+ }
98
+
86
99
  /** An edited message: Recordare re-extracts what depended on it. */
87
100
  async editMessage(user: string, conversation: string, message: string, content: string): Promise<void> {
88
101
  await this.http.request('PATCH', `api/v1/ingest/conversations/${enc(conversation)}/messages/${enc(message)}`, { user, body: { content } });
@@ -98,6 +111,33 @@ export class RecordareClient {
98
111
  await this.gone(this.http.request('DELETE', `api/v1/ingest/conversations/${enc(conversation)}`, { user }));
99
112
  }
100
113
 
114
+ // ── Learned sources (WORK_PLAN 8.9, D49): text the agent learns — the client turns files into text ─────────────
115
+
116
+ /**
117
+ * The agent learns a text. A big one is sent in parts (at paragraph boundaries, `partBytes` each); any size works.
118
+ * Sending the same `externalId` again replaces the source; the same first part again is a duplicate.
119
+ */
120
+ async learnSource(user: string, source: LearnSource, partBytes = SOURCE_PART_BYTES): Promise<LearnSourceResult> {
121
+ const [first = '', ...rest] = splitParts(source.text, partBytes);
122
+ let result = await this.http.request<LearnSourceResult>('POST', 'api/v1/ingest/sources', { user, body: { ...source, text: first, final: rest.length === 0 } });
123
+ if (result.duplicate) return result;
124
+ for (const [i, text] of rest.entries()) {
125
+ result = await this.http.request<LearnSourceResult>('POST', `api/v1/ingest/sources/${enc(source.externalId)}/parts`,
126
+ { user, body: { part: i + 1, text, final: i === rest.length - 1 } });
127
+ }
128
+ return result;
129
+ }
130
+
131
+ /** The agent forgets a source (its text and passages; episodes keep a "forgotten source" marker). One it never had counts as done. */
132
+ async forgetSource(user: string, externalId: string): Promise<void> {
133
+ await this.http.request('DELETE', `api/v1/ingest/sources/${enc(externalId)}`, { user });
134
+ }
135
+
136
+ /** What the agent learned, newest first. */
137
+ sources(user: string): Promise<Source[]> {
138
+ return this.http.request('GET', 'api/v1/sources', { user });
139
+ }
140
+
101
141
  // ── The diary (API.md §4): what Recordare remembers, shown to the person in the platform's UI ──────────────────
102
142
 
103
143
  /** The timeline, newest first; pass `nextCursor` back as `cursor` for the next page. */
@@ -163,3 +203,32 @@ export class RecordareClient {
163
203
  }
164
204
  }
165
205
  }
206
+
207
+ /** A text in parts of at most `maxBytes` UTF-8 bytes, cut at paragraph boundaries (inside a long paragraph: at a space, else between characters). */
208
+ export function splitParts(text: string, maxBytes: number): string[] {
209
+ const size = (t: string) => Buffer.byteLength(t, 'utf8');
210
+ if (size(text) <= maxBytes) return [text];
211
+ const parts: string[] = [];
212
+ let current = '';
213
+ const push = (piece: string) => {
214
+ const joined = current ? `${current}\n\n${piece}` : piece;
215
+ if (size(joined) <= maxBytes) { current = joined; return; }
216
+ if (current) parts.push(current);
217
+ current = piece;
218
+ };
219
+ for (const paragraph of text.split(/\n\s*\n/)) {
220
+ let rest = paragraph;
221
+ while (size(rest) > maxBytes) {
222
+ let end = rest.length;
223
+ while (end > 0 && size(rest.slice(0, end)) > maxBytes) end = Math.floor(end * 0.9);
224
+ const space = rest.lastIndexOf(' ', end);
225
+ let cut = space > end / 2 ? space : end;
226
+ if (/[\uD800-\uDBFF]/.test(rest.charAt(cut - 1))) cut--; // never between the halves of a surrogate pair
227
+ push(rest.slice(0, cut));
228
+ rest = rest.slice(cut).trimStart();
229
+ }
230
+ if (rest) push(rest);
231
+ }
232
+ if (current) parts.push(current);
233
+ return parts;
234
+ }
package/src/contract.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** The client's user the request acts for (with a client key). */
11
11
  export const USER_HEADER = 'X-Recordare-User';
12
- /** The conversation a read happens in: Recordare resolves who will see the answer from it (viewer context). */
12
+ /** The conversation a read or write happens in: evidence of MCP writes, the current turn left out of recall (D50: no viewer filter). */
13
13
  export const CONVERSATION_HEADER = 'X-Recordare-Conversation';
14
14
 
15
15
  /** Largest message content Recordare accepts (UTF-8 bytes). */
@@ -17,16 +17,20 @@ export const MAX_CONTENT_BYTES = 64 * 1024;
17
17
  /** Largest number of messages in one ingest request. */
18
18
  export const MAX_MESSAGES_PER_REQUEST = 500;
19
19
 
20
- /** A personal memory, or one shared by everyone using the account (an entity: a home device, a robot, a place). */
21
- export type MemoryKind = 'human' | 'entity';
20
+ /**
21
+ * A personal memory (the account holder is "I": what arrives without a declared identity is the memory's own), or an
22
+ * entity's (a home device, a robot, a place: what arrives undeclared is "someone"'s) — D50.
23
+ */
24
+ export type MemoryMode = 'personal' | 'entity';
25
+ /** The grammatical gender of the memory's first person in gendered languages (default masculine). */
26
+ export type MemoryGender = 'masculine' | 'feminine' | 'neutral';
22
27
 
23
28
  /** `GET api/v1/me`: who a request acts for. */
24
29
  export interface Me {
25
- ownerId: string;
30
+ memoryId: string;
26
31
  displayName: string | null;
27
- kind: MemoryKind;
28
- /** The person's consent, given by the Recordare admin: until then ingest stores nothing. */
29
- episodicEnabled: boolean;
32
+ mode: MemoryMode;
33
+ gender: MemoryGender;
30
34
  /** Recordare Atlas, when installed: link it for the platform's admins only (it shows every person's activity). */
31
35
  atlasUrl?: string;
32
36
  via: string;
@@ -38,7 +42,9 @@ export interface MeSettings {
38
42
  /** Follows the platform's profile: send it again whenever the user renames themselves. */
39
43
  displayName?: string;
40
44
  /** Accepted only while the memory is empty (else MemoryNotEmptyError). */
41
- kind?: MemoryKind;
45
+ mode?: MemoryMode;
46
+ /** From the account's profile; changes any time. */
47
+ gender?: MemoryGender;
42
48
  }
43
49
 
44
50
  export type ParticipantIdentity = { externalUserId: string } | { channel: string; externalId: string };
@@ -46,14 +52,21 @@ export type ParticipantIdentity = { externalUserId: string } | { channel: string
46
52
  export interface IngestParticipant {
47
53
  /** The conversation-local reference messages use in `authorRef`. */
48
54
  ref: string;
49
- role: 'owner' | 'assistant' | 'other';
55
+ role: 'holder' | 'assistant' | 'other';
50
56
  displayName?: string;
51
- /** A verified identity on the platform: lets Recordare link the participant to a known person. */
57
+ /**
58
+ * The participant's id on the platform (its user id) or on a channel: Recordare links it to a contact of this memory
59
+ * (created on first sight) — never to another memory.
60
+ */
52
61
  identity?: ParticipantIdentity;
53
62
  }
54
63
 
55
- /** Where a conversation comes from (`mcp_tool` is the service's own, never sent by a client). */
56
- export type ConversationSource = 'chat' | 'voice' | 'import_chat' | 'import_social' | 'import_email' | 'import_notes' | 'interview';
64
+ /**
65
+ * Where a conversation comes from (`mcp_tool` is the service's own, never sent by a client). `document`: a document
66
+ * given to the agent; `perception`: what a device perceives; `ambient`: continuous listening.
67
+ */
68
+ export type ConversationSource = 'chat' | 'voice' | 'import_chat' | 'import_social' | 'import_email' | 'import_notes' | 'interview'
69
+ | 'document' | 'perception' | 'ambient';
57
70
 
58
71
  export interface IngestConversation {
59
72
  /** The platform's own stable id of the conversation. */
@@ -77,6 +90,8 @@ export interface IngestMessage {
77
90
  sentAt: string;
78
91
  /** Replace the stored content of an existing message (an edit). */
79
92
  upsert?: boolean;
93
+ /** The agent's own content (knowledge given to it, its perceptions, a document); `user` or `other` messages only. */
94
+ own?: boolean;
80
95
  }
81
96
 
82
97
  export interface IngestRequest {
@@ -89,12 +104,11 @@ export interface IngestRequest {
89
104
  }
90
105
 
91
106
  export interface IngestResult {
107
+ /** null only when no message was sent. */
92
108
  conversationId: string | null;
93
109
  accepted: number;
94
110
  duplicates: number;
95
111
  conflicts: string[];
96
- /** false = consent not given yet: nothing was stored. */
97
- stored: boolean;
98
112
  }
99
113
 
100
114
  // ── The diary: read / write API for host UIs (API.md §4) ─────────────────────────────────────────────────────────
@@ -173,9 +187,55 @@ export interface Note {
173
187
  recordedAt: string;
174
188
  }
175
189
 
190
+ /** Size of the parts `learnSource` sends a big text in (UTF-8 bytes; the service's default request limit is 16 MB). */
191
+ export const SOURCE_PART_BYTES = 4 * 1024 * 1024;
192
+
193
+ export type SourceKind = 'document' | 'page' | 'note' | 'book' | 'own_text';
194
+
195
+ /** `POST api/v1/ingest/sources` (WORK_PLAN 8.9, D49): a text the agent learns — the client turns files into text. */
196
+ export interface LearnSource {
197
+ /** The client's own id for the source: sending it again replaces it (a new version). */
198
+ externalId: string;
199
+ title: string;
200
+ kind?: SourceKind;
201
+ author?: string;
202
+ uri?: string;
203
+ language?: string;
204
+ learnedAt?: string;
205
+ /** Who gave it: "me" (the memory's self), "someone", or a person by name. Default "me". */
206
+ providedBy?: 'me' | 'someone' | { name: string };
207
+ /** The conversation it was learned in: its extraction then tells of it. */
208
+ conversation?: { externalId: string };
209
+ text: string;
210
+ }
211
+
212
+ export interface LearnSourceResult {
213
+ sourceId: string;
214
+ status: 'receiving' | 'indexing' | 'ready';
215
+ parts: number;
216
+ passages: number;
217
+ duplicate: boolean;
218
+ }
219
+
220
+ /** `GET api/v1/sources`: a learned source (the Diary's "what I learned"). */
221
+ export interface Source {
222
+ id: string;
223
+ externalId: string;
224
+ title: string;
225
+ kind: string;
226
+ author: string | null;
227
+ uri: string | null;
228
+ providedBy: { kind: 'self' } | { kind: 'someone' } | { kind: 'contact'; name: string };
229
+ learnedAt: string;
230
+ status: 'receiving' | 'indexing' | 'ready';
231
+ chars: number;
232
+ passages: number;
233
+ episodeIds: string[];
234
+ }
235
+
176
236
  /** `POST api/v1/context`: the memories relevant to the message a host is about to answer (WORK_PLAN 5.7). */
177
237
  export interface MemoryContext {
178
- /** A fenced `<memory-context>` block to append to the prompt, or null (nothing relevant, not owner-only, or off). */
238
+ /** A fenced `<memory-context>` block to append to the prompt, or null (nothing relevant, or off). */
179
239
  block: string | null;
180
240
  items: number;
181
241
  }
package/src/errors.ts CHANGED
@@ -34,10 +34,10 @@ export class RecordareUnavailableError extends Error {
34
34
  }
35
35
  }
36
36
 
37
- /** The memory kind can change only while the memory is empty (`PATCH /me {kind}` → 409). */
37
+ /** The memory mode can change only while the memory is empty (`PATCH /me {mode}` → 409). */
38
38
  export class MemoryNotEmptyError extends Error {
39
39
  constructor() {
40
- super('The memory kind can change only while the memory is empty');
40
+ super('The memory mode can change only while the memory is empty');
41
41
  this.name = 'MemoryNotEmptyError';
42
42
  }
43
43
  }
package/src/index.ts CHANGED
@@ -7,5 +7,6 @@ export * from './errors.js';
7
7
  export { type ClientOptions, type FetchLike, parseRetryAfter } from './http.js';
8
8
  export { RecordareClient } from './client.js';
9
9
  export { RecordareMcp, type McpOptions, type Tool, type ToolResult } from './mcp.js';
10
- export { PersonDirectory, type ConsentState, type Person, type PersonDirectoryOptions } from './people.js';
10
+ export { PersonDirectory, type Person, type PersonDirectoryOptions } from './people.js';
11
+ export { TOOLS } from './tools.js';
11
12
  export { afterFailure, backoffMs, DEFAULT_DELIVERY, type DeliveryPolicy, type Next } from './delivery.js';
package/src/mcp.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // Copyright © 2026 Andrea Genovese
3
3
 
4
4
  /**
5
- * Recall over MCP with the official SDK (streamable HTTP). Recordare fixes the owner when a session starts and resolves
6
- * the viewer context from the conversation header (docs/API.md §1), so a session belongs to one user AND one
5
+ * Recall over MCP with the official SDK (streamable HTTP). Recordare fixes the memory when a session starts and resolves
6
+ * the conversation from its header (docs/API.md §1: evidence of writes), so a session belongs to one user AND one
7
7
  * conversation: both headers are bound here, in code — neither the LLM nor the user can change them. Sessions are
8
8
  * reused (bounded, least recently used closed first) and an expired one is re-opened once.
9
9
  */
@@ -99,7 +99,7 @@ export class RecordareMcp {
99
99
  },
100
100
  requestInit: { headers: { ...this.http.headers(user), [CONVERSATION_HEADER]: conversation } },
101
101
  });
102
- const client = new Client(this.options.clientInfo ?? { name: 'recordare-client', version: '0.1.0' });
102
+ const client = new Client(this.options.clientInfo ?? { name: 'recordare-client', version: '0.2.0' });
103
103
  await client.connect(transport);
104
104
  return client;
105
105
  }
package/src/people.ts CHANGED
@@ -2,21 +2,18 @@
2
2
  // Copyright © 2026 Andrea Genovese
3
3
 
4
4
  /**
5
- * Who each of the platform's users is in Recordare, cached: the person (ownerId), their consent, the kind of memory and
6
- * the Atlas address. The synchronous readers never wait on Recordare: they answer from the cache (even if stale) and
7
- * refresh in the background. Each lookup also keeps the person's name in sync with the platform's profile.
5
+ * Who each of the platform's users is in Recordare, cached: the memory (memoryId), its mode and the Atlas
6
+ * address. Recordare has no consent flag (D50): the platform's own switch (`enabled`) decides whether it is contacted.
7
+ * The synchronous readers never wait on Recordare: they answer from the cache (even if stale) and refresh in the
8
+ * background. Each lookup also keeps the person's name in sync with the platform's profile.
8
9
  */
9
- import { type MemoryKind } from './contract.js';
10
+ import { type MemoryMode } from './contract.js';
10
11
  import { type RecordareClient } from './client.js';
11
12
 
12
- /** The person's memory as the platform shows it: consent given, not yet, or Recordare unreachable. */
13
- export type ConsentState = 'waiting_activation' | 'active' | 'unknown';
14
-
15
13
  export interface Person {
16
- ownerId: string | null;
17
- /** true / false, null = unknown (Recordare unreachable). */
18
- consent: boolean | null;
19
- kind: MemoryKind | null;
14
+ /** null = not known yet (not opted in on the platform, or Recordare unreachable before the first lookup). */
15
+ memoryId: string | null;
16
+ mode: MemoryMode | null;
20
17
  atlasUrl: string | null;
21
18
  }
22
19
 
@@ -33,7 +30,7 @@ export interface PersonDirectoryOptions {
33
30
  ttlMs?: number;
34
31
  /** The user on the platform; null = unknown user (treated as not enabled). */
35
32
  user: (user: string) => Promise<PlatformUser | null>;
36
- /** A person resolved (e.g. to store the ownerId with the platform's user). */
33
+ /** A person resolved (e.g. to store the memoryId with the platform's user). */
37
34
  onResolved?: (user: string, person: Person) => void | Promise<void>;
38
35
  /** Called when a lookup or a rename fails (the cache keeps the last known person). */
39
36
  onError?: (user: string, err: unknown) => void;
@@ -57,8 +54,8 @@ export class PersonDirectory {
57
54
  }
58
55
 
59
56
  /** A person known from the host's own storage (e.g. after a restart), marked stale so it is refreshed on first use. */
60
- seed(user: string, ownerId: string): void {
61
- if (!this.cache.has(user)) this.cache.set(user, { ownerId, consent: null, kind: null, atlasUrl: null, until: 0 });
57
+ seed(user: string, memoryId: string): void {
58
+ if (!this.cache.has(user)) this.cache.set(user, { memoryId, mode: null, atlasUrl: null, until: 0 });
62
59
  }
63
60
 
64
61
  /** Forgets a user (e.g. after their switch or name changed): the next read asks Recordare again. */
@@ -66,23 +63,6 @@ export class PersonDirectory {
66
63
  this.cache.delete(user);
67
64
  }
68
65
 
69
- /** Users whose consent is known to be off: a sender skips them (nothing is buffered before consent). */
70
- knownOff(): string[] {
71
- return [...this.cache].filter(([, e]) => e.consent === false).map(([u]) => u);
72
- }
73
-
74
- /** Re-checks now, waiting at most `timeoutMs`; falls back to the last known state. */
75
- async status(user: string, timeoutMs = 3_000): Promise<ConsentState> {
76
- let timer: ReturnType<typeof setTimeout> | undefined;
77
- const fresh = await Promise.race([
78
- this.refresh(user).catch(() => undefined),
79
- new Promise<undefined>((r) => { timer = setTimeout(() => r(undefined), timeoutMs); }),
80
- ]);
81
- if (timer) clearTimeout(timer);
82
- const consent = (fresh ?? this.cache.get(user))?.consent ?? null;
83
- return consent === true ? 'active' : consent === false ? 'waiting_activation' : 'unknown';
84
- }
85
-
86
66
  /** Asks Recordare now (one request in flight per user). */
87
67
  refresh(user: string): Promise<Person> {
88
68
  const running = this.inflight.get(user);
@@ -102,18 +82,18 @@ export class PersonDirectory {
102
82
  try {
103
83
  const platform = await this.options.user(user);
104
84
  // Not opted in on the platform: never contact Recordare; a person already known stays known (e.g. for tracing).
105
- if (!platform?.enabled) return remember({ ownerId: last?.ownerId ?? null, consent: null, kind: last?.kind ?? null, atlasUrl: last?.atlasUrl ?? null });
85
+ if (!platform?.enabled) return remember({ memoryId: last?.memoryId ?? null, mode: last?.mode ?? null, atlasUrl: last?.atlasUrl ?? null });
106
86
  const me = await this.client.me(user);
107
87
  const name = platform.name?.trim().slice(0, 100);
108
88
  if (name && me.displayName !== name) {
109
89
  await this.client.updateMe(user, { displayName: name }).catch((err) => this.options.onError?.(user, err));
110
90
  }
111
- const person: Person = { ownerId: me.ownerId, consent: me.episodicEnabled, kind: me.kind, atlasUrl: me.atlasUrl ?? null };
91
+ const person: Person = { memoryId: me.memoryId, mode: me.mode, atlasUrl: me.atlasUrl ?? null };
112
92
  await this.options.onResolved?.(user, person);
113
93
  return remember(person);
114
94
  } catch (err) {
115
95
  this.options.onError?.(user, err);
116
- return remember({ ownerId: last?.ownerId ?? null, consent: null, kind: last?.kind ?? null, atlasUrl: last?.atlasUrl ?? null });
96
+ return remember({ memoryId: last?.memoryId ?? null, mode: last?.mode ?? null, atlasUrl: last?.atlasUrl ?? null });
117
97
  }
118
98
  }
119
99
  }