@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/README.md +46 -11
- package/README_it.md +47 -11
- package/dist/client.d.ts +23 -5
- package/dist/client.js +77 -7
- package/dist/contract.d.ts +80 -15
- package/dist/contract.js +3 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mcp.js +3 -3
- package/dist/people.d.ts +10 -16
- package/dist/people.js +5 -21
- package/dist/tools.d.ts +226 -0
- package/dist/tools.js +286 -0
- package/package.json +2 -2
- package/src/client.ts +76 -7
- package/src/contract.ts +75 -15
- package/src/errors.ts +2 -2
- package/src/index.ts +2 -1
- package/src/mcp.ts +3 -3
- package/src/people.ts +14 -34
- package/src/tools.ts +287 -0
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,
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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: []
|
|
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:
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
30
|
+
memoryId: string;
|
|
26
31
|
displayName: string | null;
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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: '
|
|
55
|
+
role: 'holder' | 'assistant' | 'other';
|
|
50
56
|
displayName?: string;
|
|
51
|
-
/**
|
|
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
|
-
/**
|
|
56
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6
|
-
* the
|
|
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.
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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,
|
|
61
|
-
if (!this.cache.has(user)) this.cache.set(user, {
|
|
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({
|
|
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 = {
|
|
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({
|
|
96
|
+
return remember({ memoryId: last?.memoryId ?? null, mode: last?.mode ?? null, atlasUrl: last?.atlasUrl ?? null });
|
|
117
97
|
}
|
|
118
98
|
}
|
|
119
99
|
}
|