@rool-dev/sdk 0.1.0-dev.8511433

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 (54) hide show
  1. package/README.md +1070 -0
  2. package/dist/apps.d.ts +29 -0
  3. package/dist/apps.d.ts.map +1 -0
  4. package/dist/apps.js +88 -0
  5. package/dist/apps.js.map +1 -0
  6. package/dist/auth-browser.d.ts +80 -0
  7. package/dist/auth-browser.d.ts.map +1 -0
  8. package/dist/auth-browser.js +370 -0
  9. package/dist/auth-browser.js.map +1 -0
  10. package/dist/auth-node.d.ts +46 -0
  11. package/dist/auth-node.d.ts.map +1 -0
  12. package/dist/auth-node.js +315 -0
  13. package/dist/auth-node.js.map +1 -0
  14. package/dist/auth.d.ts +56 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +96 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/client.d.ts +202 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +472 -0
  21. package/dist/client.js.map +1 -0
  22. package/dist/event-emitter.d.ts +38 -0
  23. package/dist/event-emitter.d.ts.map +1 -0
  24. package/dist/event-emitter.js +80 -0
  25. package/dist/event-emitter.js.map +1 -0
  26. package/dist/graphql.d.ts +71 -0
  27. package/dist/graphql.d.ts.map +1 -0
  28. package/dist/graphql.js +487 -0
  29. package/dist/graphql.js.map +1 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +11 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/jsonld.d.ts +47 -0
  35. package/dist/jsonld.d.ts.map +1 -0
  36. package/dist/jsonld.js +137 -0
  37. package/dist/jsonld.js.map +1 -0
  38. package/dist/media.d.ts +52 -0
  39. package/dist/media.d.ts.map +1 -0
  40. package/dist/media.js +173 -0
  41. package/dist/media.js.map +1 -0
  42. package/dist/space.d.ts +358 -0
  43. package/dist/space.d.ts.map +1 -0
  44. package/dist/space.js +1121 -0
  45. package/dist/space.js.map +1 -0
  46. package/dist/subscription.d.ts +57 -0
  47. package/dist/subscription.d.ts.map +1 -0
  48. package/dist/subscription.js +296 -0
  49. package/dist/subscription.js.map +1 -0
  50. package/dist/types.d.ts +409 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +6 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +65 -0
package/dist/space.js ADDED
@@ -0,0 +1,1121 @@
1
+ import { immutableJSONPatch } from 'immutable-json-patch';
2
+ import { zipSync, unzipSync } from 'fflate';
3
+ import { EventEmitter } from './event-emitter.js';
4
+ import { SpaceSubscriptionManager } from './subscription.js';
5
+ import { toJsonLd, fromJsonLd, findAllStrings, rewriteStrings } from './jsonld.js';
6
+ // 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
7
+ const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
8
+ export function generateEntityId() {
9
+ let result = '';
10
+ for (let i = 0; i < 6; i++) {
11
+ result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
12
+ }
13
+ return result;
14
+ }
15
+ // Content type <-> file extension mapping for archive media files
16
+ const MIME_TO_EXT = {
17
+ 'image/jpeg': '.jpg',
18
+ 'image/png': '.png',
19
+ 'image/gif': '.gif',
20
+ 'image/webp': '.webp',
21
+ 'image/svg+xml': '.svg',
22
+ 'audio/mpeg': '.mp3',
23
+ 'audio/wav': '.wav',
24
+ 'audio/ogg': '.ogg',
25
+ 'video/mp4': '.mp4',
26
+ 'video/webm': '.webm',
27
+ 'application/pdf': '.pdf',
28
+ 'text/plain': '.txt',
29
+ 'application/json': '.json',
30
+ };
31
+ const EXT_TO_MIME = {
32
+ '.jpg': 'image/jpeg',
33
+ '.jpeg': 'image/jpeg',
34
+ '.png': 'image/png',
35
+ '.gif': 'image/gif',
36
+ '.webp': 'image/webp',
37
+ '.svg': 'image/svg+xml',
38
+ '.mp3': 'audio/mpeg',
39
+ '.wav': 'audio/wav',
40
+ '.ogg': 'audio/ogg',
41
+ '.mp4': 'video/mp4',
42
+ '.webm': 'video/webm',
43
+ '.pdf': 'application/pdf',
44
+ '.txt': 'text/plain',
45
+ '.json': 'application/json',
46
+ };
47
+ function getExtensionFromContentType(contentType) {
48
+ // Strip parameters like charset
49
+ const base = contentType.split(';')[0].trim();
50
+ return MIME_TO_EXT[base] ?? '.bin';
51
+ }
52
+ function getContentTypeFromFilename(filename) {
53
+ const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
54
+ return EXT_TO_MIME[ext] ?? 'application/octet-stream';
55
+ }
56
+ /**
57
+ * First-class Space object.
58
+ *
59
+ * Features:
60
+ * - High-level object/link operations
61
+ * - Built-in undo/redo with checkpoints
62
+ * - Metadata management
63
+ * - Event emission for state changes
64
+ * - Real-time updates via space-specific subscription
65
+ */
66
+ export class RoolSpace extends EventEmitter {
67
+ _id;
68
+ _name;
69
+ _role;
70
+ _userId;
71
+ _conversationId;
72
+ _data;
73
+ graphqlClient;
74
+ mediaClient;
75
+ subscriptionManager;
76
+ onCloseCallback;
77
+ constructor(config) {
78
+ super();
79
+ this._id = config.id;
80
+ this._name = config.name;
81
+ this._role = config.role;
82
+ this._userId = config.userId;
83
+ this._conversationId = config.conversationId ?? generateEntityId();
84
+ this._data = config.initialData;
85
+ this.graphqlClient = config.graphqlClient;
86
+ this.mediaClient = config.mediaClient;
87
+ this.onCloseCallback = config.onClose;
88
+ // Create space-level subscription
89
+ this.subscriptionManager = new SpaceSubscriptionManager({
90
+ graphqlUrl: config.graphqlUrl,
91
+ authManager: config.authManager,
92
+ spaceId: this._id,
93
+ conversationId: this._conversationId,
94
+ onEvent: (event) => this.handleSpaceEvent(event),
95
+ onConnectionStateChanged: () => {
96
+ // Space-level connection state (could emit events if needed)
97
+ },
98
+ onError: (error) => {
99
+ console.error(`[RoolSpace ${this._id}] Subscription error:`, error);
100
+ },
101
+ });
102
+ // Start subscription
103
+ void this.subscriptionManager.subscribe();
104
+ }
105
+ // ===========================================================================
106
+ // Properties
107
+ // ===========================================================================
108
+ get id() {
109
+ return this._id;
110
+ }
111
+ get name() {
112
+ return this._name;
113
+ }
114
+ get role() {
115
+ return this._role;
116
+ }
117
+ /** Current user's ID (for identifying own interactions) */
118
+ get userId() {
119
+ return this._userId;
120
+ }
121
+ /**
122
+ * Get the conversation ID for this space instance.
123
+ * Used for AI context tracking and echo suppression.
124
+ */
125
+ get conversationId() {
126
+ return this._conversationId;
127
+ }
128
+ /**
129
+ * Set the conversation ID for AI context tracking.
130
+ * Emits 'conversationIdChanged' event.
131
+ */
132
+ set conversationId(value) {
133
+ if (value === this._conversationId)
134
+ return;
135
+ const previous = this._conversationId;
136
+ this._conversationId = value;
137
+ this.emit('conversationIdChanged', {
138
+ previousConversationId: previous,
139
+ newConversationId: value,
140
+ });
141
+ }
142
+ get isReadOnly() {
143
+ return this._role === 'viewer';
144
+ }
145
+ // ===========================================================================
146
+ // Conversation Access
147
+ // ===========================================================================
148
+ /**
149
+ * Get interactions for this space's current conversationId.
150
+ * Returns the interactions array.
151
+ */
152
+ getInteractions() {
153
+ return this._data.conversations?.[this._conversationId]?.interactions ?? [];
154
+ }
155
+ /**
156
+ * Get interactions for a specific conversation ID.
157
+ * Useful for viewing other conversations in the space.
158
+ */
159
+ getInteractionsById(conversationId) {
160
+ return this._data.conversations?.[conversationId]?.interactions ?? [];
161
+ }
162
+ /**
163
+ * Get all conversation IDs that have conversations in this space.
164
+ */
165
+ getConversationIds() {
166
+ return Object.keys(this._data.conversations ?? {});
167
+ }
168
+ // ===========================================================================
169
+ // Space Lifecycle
170
+ // ===========================================================================
171
+ /**
172
+ * Rename this space.
173
+ */
174
+ async rename(newName) {
175
+ const oldName = this._name;
176
+ this._name = newName;
177
+ try {
178
+ await this.graphqlClient.renameSpace(this._id, newName, this._conversationId);
179
+ }
180
+ catch (error) {
181
+ this._name = oldName;
182
+ throw error;
183
+ }
184
+ }
185
+ /**
186
+ * Close this space and clean up resources.
187
+ * Stops real-time subscription and unregisters from client.
188
+ */
189
+ close() {
190
+ this.subscriptionManager.destroy();
191
+ this.onCloseCallback(this._id);
192
+ this.removeAllListeners();
193
+ }
194
+ // ===========================================================================
195
+ // Undo / Redo (Server-managed checkpoints)
196
+ // ===========================================================================
197
+ /**
198
+ * Create a checkpoint (seal current batch of changes).
199
+ * Patches accumulate automatically - this seals them with a label.
200
+ * @returns The checkpoint ID
201
+ */
202
+ async checkpoint(label = 'Change') {
203
+ const result = await this.graphqlClient.checkpoint(this._id, label, this._conversationId);
204
+ return result.checkpointId;
205
+ }
206
+ /**
207
+ * Check if undo is available.
208
+ */
209
+ async canUndo() {
210
+ const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
211
+ return status.canUndo;
212
+ }
213
+ /**
214
+ * Check if redo is available.
215
+ */
216
+ async canRedo() {
217
+ const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
218
+ return status.canRedo;
219
+ }
220
+ /**
221
+ * Undo the most recent batch of changes.
222
+ * Reverses your most recent batch (sealed or open).
223
+ * Conflicting patches (modified by others) are silently skipped.
224
+ * @returns true if undo was performed
225
+ */
226
+ async undo() {
227
+ const result = await this.graphqlClient.undo(this._id, this._conversationId);
228
+ // Server broadcasts space_patched if successful, which updates local state
229
+ return result.success;
230
+ }
231
+ /**
232
+ * Redo a previously undone batch of changes.
233
+ * @returns true if redo was performed
234
+ */
235
+ async redo() {
236
+ const result = await this.graphqlClient.redo(this._id, this._conversationId);
237
+ // Server broadcasts space_patched if successful, which updates local state
238
+ return result.success;
239
+ }
240
+ /**
241
+ * Clear checkpoint history for this conversation.
242
+ */
243
+ async clearHistory() {
244
+ await this.graphqlClient.clearCheckpointHistory(this._id, this._conversationId);
245
+ }
246
+ // ===========================================================================
247
+ // Object Operations
248
+ // ===========================================================================
249
+ /**
250
+ * Get an object's data by ID.
251
+ * Returns just the data portion (RoolObject), not the full entry with meta/links.
252
+ */
253
+ async getObject(objectId) {
254
+ return this._data.objects[objectId]?.data;
255
+ }
256
+ /**
257
+ * Get an object's stat (audit information).
258
+ * Returns modification timestamp and author, or undefined if object not found.
259
+ */
260
+ async stat(objectId) {
261
+ const entry = this._data.objects[objectId];
262
+ if (!entry)
263
+ return undefined;
264
+ return {
265
+ modifiedAt: entry.modifiedAt,
266
+ modifiedBy: entry.modifiedBy,
267
+ modifiedByName: entry.modifiedByName,
268
+ };
269
+ }
270
+ /**
271
+ * Find objects using structured filters and natural language.
272
+ * @param options.where - Structured field requirements (exact match). Use {{placeholder}} for semantic matching.
273
+ * @param options.prompt - Natural language query/refinement
274
+ * @param options.limit - Maximum number of results to return
275
+ * @param options.objectIds - Scope search to specific objects
276
+ * @returns The matching objects and a message from the AI
277
+ *
278
+ * @example
279
+ * // Exact match
280
+ * const { objects } = await space.findObjects({ where: { type: 'article' } });
281
+ *
282
+ * @example
283
+ * // Natural language
284
+ * const { objects, message } = await space.findObjects({
285
+ * prompt: 'articles about space exploration'
286
+ * });
287
+ *
288
+ * @example
289
+ * // Combined - structured + semantic
290
+ * const { objects } = await space.findObjects({
291
+ * where: { type: 'article', category: '{{something about food}}' },
292
+ * prompt: 'published in the last month',
293
+ * limit: 10
294
+ * });
295
+ */
296
+ async findObjects(options) {
297
+ const order = options.order ?? 'desc';
298
+ // Check if we need AI (prompt or placeholders in where)
299
+ const needsAI = options.prompt ||
300
+ (options.where && JSON.stringify(options.where).includes('{{'));
301
+ // If no AI needed, filter locally (avoids server round trip)
302
+ if (!needsAI) {
303
+ // Get entries (not just data) so we can sort by modifiedAt
304
+ let entries = Object.entries(this._data.objects);
305
+ // Apply where clause (exact match)
306
+ if (options.where && Object.keys(options.where).length > 0) {
307
+ entries = entries.filter(([, entry]) => Object.entries(options.where).every(([key, value]) => entry.data[key] === value));
308
+ }
309
+ // Apply scope filter
310
+ if (options.objectIds && options.objectIds.length > 0) {
311
+ const scope = new Set(options.objectIds);
312
+ entries = entries.filter(([id]) => scope.has(id));
313
+ }
314
+ // Sort by modifiedAt
315
+ entries.sort((a, b) => {
316
+ const aTime = a[1].modifiedAt ?? 0;
317
+ const bTime = b[1].modifiedAt ?? 0;
318
+ return order === 'desc' ? bTime - aTime : aTime - bTime;
319
+ });
320
+ // Apply limit
321
+ if (options.limit) {
322
+ entries = entries.slice(0, options.limit);
323
+ }
324
+ const objects = entries.map(([, entry]) => entry.data);
325
+ return {
326
+ objects,
327
+ message: `Found ${objects.length} object(s) matching criteria`,
328
+ };
329
+ }
330
+ // Otherwise, use server (with AI)
331
+ return this.graphqlClient.findObjects(this._id, options, this._conversationId);
332
+ }
333
+ /**
334
+ * Get all object IDs.
335
+ * @param options.limit - Maximum number of IDs to return
336
+ * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
337
+ */
338
+ getObjectIds(options) {
339
+ const order = options?.order ?? 'desc';
340
+ let entries = Object.entries(this._data.objects);
341
+ // Sort by modifiedAt
342
+ entries.sort((a, b) => {
343
+ const aTime = a[1].modifiedAt ?? 0;
344
+ const bTime = b[1].modifiedAt ?? 0;
345
+ return order === 'desc' ? bTime - aTime : aTime - bTime;
346
+ });
347
+ let ids = entries.map(([id]) => id);
348
+ if (options?.limit) {
349
+ ids = ids.slice(0, options.limit);
350
+ }
351
+ return ids;
352
+ }
353
+ /**
354
+ * Create a new object with optional AI generation.
355
+ * @param options.data - Object data fields (any key-value pairs). Optionally include `id` to use a custom ID. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
356
+ * @param options.prompt - AI prompt for content generation (optional).
357
+ * @param options.ephemeral - If true, the operation won't be recorded in conversation history.
358
+ * @returns The created object (with AI-filled content) and message
359
+ */
360
+ async createObject(options) {
361
+ const { data, prompt, ephemeral } = options;
362
+ // Use data.id if provided (string), otherwise generate
363
+ const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
364
+ // Validate ID format: alphanumeric, hyphens, underscores only
365
+ if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
366
+ throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
367
+ }
368
+ // Fail if object already exists
369
+ if (this._data.objects[objectId]) {
370
+ throw new Error(`Object "${objectId}" already exists`);
371
+ }
372
+ const dataWithId = { ...data, id: objectId };
373
+ // Build the entry for local state (optimistic - server will overwrite audit fields)
374
+ const entry = {
375
+ links: {},
376
+ data: dataWithId,
377
+ modifiedAt: Date.now(),
378
+ modifiedBy: this._userId,
379
+ modifiedByName: null,
380
+ };
381
+ // Update local state immediately (optimistic)
382
+ this._data.objects[objectId] = entry;
383
+ this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
384
+ // Await server call (may trigger AI processing that updates local state via patches)
385
+ try {
386
+ const message = await this.graphqlClient.createObject(this.id, dataWithId, this._conversationId, prompt, ephemeral);
387
+ // Return current state (may have been updated by AI patches)
388
+ return { object: this._data.objects[objectId].data, message };
389
+ }
390
+ catch (error) {
391
+ console.error('[Space] Failed to create object:', error);
392
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
393
+ throw error;
394
+ }
395
+ }
396
+ /**
397
+ * Update an existing object.
398
+ * @param objectId - The ID of the object to update
399
+ * @param options.data - Fields to add or update. Pass null or undefined to delete a field. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
400
+ * @param options.prompt - AI prompt for content editing (optional).
401
+ * @param options.ephemeral - If true, the operation won't be recorded in conversation history.
402
+ * @returns The updated object (with AI-filled content) and message
403
+ */
404
+ async updateObject(objectId, options) {
405
+ const entry = this._data.objects[objectId];
406
+ if (!entry) {
407
+ throw new Error(`Object ${objectId} not found for update`);
408
+ }
409
+ const { data, ephemeral } = options;
410
+ // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
411
+ if (data?.id !== undefined && data.id !== null) {
412
+ throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
413
+ }
414
+ if (data && ('id' in data)) {
415
+ throw new Error('Cannot delete id field. The id field is immutable after creation.');
416
+ }
417
+ // Normalize undefined to null (for JSON serialization) and build server data
418
+ let serverData;
419
+ if (data) {
420
+ serverData = {};
421
+ for (const [key, value] of Object.entries(data)) {
422
+ // Convert undefined to null for wire protocol
423
+ serverData[key] = value === undefined ? null : value;
424
+ }
425
+ }
426
+ // Build local updates (apply deletions and updates)
427
+ if (data) {
428
+ for (const [key, value] of Object.entries(data)) {
429
+ if (value === null || value === undefined) {
430
+ delete entry.data[key];
431
+ }
432
+ else {
433
+ entry.data[key] = value;
434
+ }
435
+ }
436
+ }
437
+ // Emit semantic event with updated object
438
+ if (data) {
439
+ this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
440
+ }
441
+ // Await server call (may trigger AI processing that updates local state via patches)
442
+ try {
443
+ const message = await this.graphqlClient.updateObject(this.id, objectId, this._conversationId, serverData, options.prompt, ephemeral);
444
+ // Return current state (may have been updated by AI patches)
445
+ return { object: this._data.objects[objectId].data, message };
446
+ }
447
+ catch (error) {
448
+ console.error('[Space] Failed to update object:', error);
449
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
450
+ throw error;
451
+ }
452
+ }
453
+ /**
454
+ * Delete objects by IDs.
455
+ * Outbound links are automatically deleted with the object.
456
+ * Inbound links become orphans (tolerated).
457
+ */
458
+ async deleteObjects(objectIds) {
459
+ if (objectIds.length === 0)
460
+ return;
461
+ const deletedObjectIds = [];
462
+ // Collect links that will be orphaned (for events)
463
+ const deletedLinks = [];
464
+ for (const objectId of objectIds) {
465
+ const entry = this._data.objects[objectId];
466
+ if (entry) {
467
+ // Collect outbound links for deletion events
468
+ for (const [relation, targets] of Object.entries(entry.links)) {
469
+ for (const targetId of Object.keys(targets)) {
470
+ deletedLinks.push({ sourceId: objectId, targetId, relation });
471
+ }
472
+ }
473
+ }
474
+ }
475
+ // Remove objects (local state)
476
+ for (const objectId of objectIds) {
477
+ if (this._data.objects[objectId]) {
478
+ delete this._data.objects[objectId];
479
+ deletedObjectIds.push(objectId);
480
+ }
481
+ }
482
+ // Emit semantic events
483
+ for (const link of deletedLinks) {
484
+ this.emit('unlinked', { ...link, source: 'local_user' });
485
+ }
486
+ for (const objectId of deletedObjectIds) {
487
+ this.emit('objectDeleted', { objectId, source: 'local_user' });
488
+ }
489
+ // Await server call
490
+ try {
491
+ await this.graphqlClient.deleteObjects(this.id, objectIds, this._conversationId);
492
+ }
493
+ catch (error) {
494
+ console.error('[Space] Failed to delete objects:', error);
495
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
496
+ throw error;
497
+ }
498
+ }
499
+ // ===========================================================================
500
+ // Conversation Management
501
+ // ===========================================================================
502
+ /**
503
+ * Delete a conversation and its interaction history.
504
+ * Defaults to the current conversation if no conversationId is provided.
505
+ */
506
+ async deleteConversation(conversationId) {
507
+ const targetConversationId = conversationId ?? this._conversationId;
508
+ // Optimistic local update
509
+ if (this._data.conversations?.[targetConversationId]) {
510
+ delete this._data.conversations[targetConversationId];
511
+ }
512
+ // Emit event
513
+ this.emit('conversationUpdated', {
514
+ conversationId: targetConversationId,
515
+ source: 'local_user',
516
+ });
517
+ // Call server
518
+ try {
519
+ await this.graphqlClient.deleteConversation(this.id, targetConversationId);
520
+ }
521
+ catch (error) {
522
+ console.error('[Space] Failed to delete conversation:', error);
523
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
524
+ throw error;
525
+ }
526
+ }
527
+ /**
528
+ * Rename a conversation.
529
+ * If the conversation doesn't exist, it will be created with the given name.
530
+ */
531
+ async renameConversation(conversationId, name) {
532
+ // Optimistic local update - auto-create if needed
533
+ if (!this._data.conversations) {
534
+ this._data.conversations = {};
535
+ }
536
+ if (!this._data.conversations[conversationId]) {
537
+ this._data.conversations[conversationId] = {
538
+ name,
539
+ createdAt: Date.now(),
540
+ interactions: [],
541
+ };
542
+ }
543
+ else {
544
+ this._data.conversations[conversationId].name = name;
545
+ }
546
+ // Emit event
547
+ this.emit('conversationUpdated', {
548
+ conversationId,
549
+ source: 'local_user',
550
+ });
551
+ // Call server
552
+ try {
553
+ await this.graphqlClient.renameConversation(this.id, conversationId, name);
554
+ }
555
+ catch (error) {
556
+ console.error('[Space] Failed to rename conversation:', error);
557
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
558
+ throw error;
559
+ }
560
+ }
561
+ /**
562
+ * List all conversations in this space with summary info.
563
+ */
564
+ async listConversations() {
565
+ return this.graphqlClient.listConversations(this.id);
566
+ }
567
+ // ===========================================================================
568
+ // Link Operations
569
+ // ===========================================================================
570
+ /**
571
+ * Create a link between objects.
572
+ * Links are stored on the source object.
573
+ */
574
+ async link(sourceId, relation, targetId) {
575
+ const entry = this._data.objects[sourceId];
576
+ if (!entry) {
577
+ throw new Error(`Source object ${sourceId} not found`);
578
+ }
579
+ // Update local state immediately
580
+ if (!entry.links[relation]) {
581
+ entry.links[relation] = [];
582
+ }
583
+ if (!entry.links[relation].includes(targetId)) {
584
+ entry.links[relation].push(targetId);
585
+ }
586
+ this.emit('linked', { sourceId, relation, targetId, source: 'local_user' });
587
+ // Await server call
588
+ try {
589
+ await this.graphqlClient.link(this.id, sourceId, relation, targetId, this._conversationId);
590
+ }
591
+ catch (error) {
592
+ console.error('[Space] Failed to create link:', error);
593
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
594
+ throw error;
595
+ }
596
+ }
597
+ /**
598
+ * Remove links from a source object.
599
+ * Three forms:
600
+ * - unlink(source, relation, target): remove one specific link
601
+ * - unlink(source, relation): clear all targets for that relation
602
+ * - unlink(source): clear ALL relations on the source
603
+ * @returns true if any links were removed
604
+ */
605
+ async unlink(sourceId, relation, targetId) {
606
+ const entry = this._data.objects[sourceId];
607
+ if (!entry) {
608
+ throw new Error(`Source object ${sourceId} not found`);
609
+ }
610
+ const deletedLinks = [];
611
+ // Update local state based on which parameters are provided
612
+ if (relation && targetId) {
613
+ // Remove one specific link: source.relation -> target
614
+ const existing = entry.links[relation] ?? [];
615
+ if (existing.includes(targetId)) {
616
+ entry.links[relation] = existing.filter(t => t !== targetId);
617
+ if (entry.links[relation].length === 0) {
618
+ delete entry.links[relation];
619
+ }
620
+ deletedLinks.push({ relation, targetId });
621
+ }
622
+ }
623
+ else if (relation && !targetId) {
624
+ // Clear all targets for this relation
625
+ if (entry.links[relation]) {
626
+ for (const target of entry.links[relation]) {
627
+ deletedLinks.push({ relation, targetId: target });
628
+ }
629
+ delete entry.links[relation];
630
+ }
631
+ }
632
+ else if (!relation && !targetId) {
633
+ // Clear ALL relations on the source
634
+ for (const [rel, targets] of Object.entries(entry.links)) {
635
+ for (const target of targets) {
636
+ deletedLinks.push({ relation: rel, targetId: target });
637
+ }
638
+ delete entry.links[rel];
639
+ }
640
+ }
641
+ // Emit semantic events
642
+ for (const link of deletedLinks) {
643
+ this.emit('unlinked', { sourceId, relation: link.relation, targetId: link.targetId, source: 'local_user' });
644
+ }
645
+ // Await server call
646
+ try {
647
+ await this.graphqlClient.unlink(this.id, sourceId, relation, targetId, this._conversationId);
648
+ }
649
+ catch (error) {
650
+ console.error('[Space] Failed to remove link:', error);
651
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
652
+ throw error;
653
+ }
654
+ return deletedLinks.length > 0;
655
+ }
656
+ /**
657
+ * Get parent objects (objects that have links pointing TO this object).
658
+ * @param relation - Optional filter by relation name
659
+ * @param options.limit - Maximum number of parents to return
660
+ * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
661
+ */
662
+ async getParents(objectId, relation, options) {
663
+ const order = options?.order ?? 'desc';
664
+ const parentEntries = [];
665
+ for (const [id, entry] of Object.entries(this._data.objects)) {
666
+ for (const [rel, targets] of Object.entries(entry.links)) {
667
+ if ((!relation || rel === relation) && targets.includes(objectId)) {
668
+ parentEntries.push([id, entry]);
669
+ break; // Found a link, move to next object
670
+ }
671
+ }
672
+ }
673
+ // Sort by modifiedAt
674
+ parentEntries.sort((a, b) => {
675
+ const aTime = a[1].modifiedAt ?? 0;
676
+ const bTime = b[1].modifiedAt ?? 0;
677
+ return order === 'desc' ? bTime - aTime : aTime - bTime;
678
+ });
679
+ let parents = parentEntries.map(([, entry]) => entry.data);
680
+ if (options?.limit) {
681
+ parents = parents.slice(0, options.limit);
682
+ }
683
+ return parents;
684
+ }
685
+ /**
686
+ * Get child objects (objects that this object has links pointing TO).
687
+ * Filters out orphan targets (targets that don't exist).
688
+ * @param relation - Optional filter by relation name
689
+ * @param options.limit - Maximum number of children to return
690
+ * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
691
+ */
692
+ async getChildren(objectId, relation, options) {
693
+ const entry = this._data.objects[objectId];
694
+ if (!entry)
695
+ return [];
696
+ const order = options?.order ?? 'desc';
697
+ const childEntries = [];
698
+ for (const [rel, targets] of Object.entries(entry.links)) {
699
+ if (!relation || rel === relation) {
700
+ for (const targetId of targets) {
701
+ // Filter orphans - only include existing targets
702
+ const targetEntry = this._data.objects[targetId];
703
+ if (targetEntry) {
704
+ childEntries.push([targetId, targetEntry]);
705
+ }
706
+ }
707
+ }
708
+ }
709
+ // Sort by modifiedAt
710
+ childEntries.sort((a, b) => {
711
+ const aTime = a[1].modifiedAt ?? 0;
712
+ const bTime = b[1].modifiedAt ?? 0;
713
+ return order === 'desc' ? bTime - aTime : aTime - bTime;
714
+ });
715
+ let children = childEntries.map(([, entry]) => entry.data);
716
+ if (options?.limit) {
717
+ children = children.slice(0, options.limit);
718
+ }
719
+ return children;
720
+ }
721
+ /**
722
+ * Get all child object IDs including orphans (targets that may not exist).
723
+ * @param relation - Optional filter by relation name
724
+ */
725
+ getChildrenIncludingOrphans(objectId, relation) {
726
+ const entry = this._data.objects[objectId];
727
+ if (!entry)
728
+ return [];
729
+ const children = [];
730
+ for (const [rel, targets] of Object.entries(entry.links)) {
731
+ if (!relation || rel === relation) {
732
+ children.push(...targets);
733
+ }
734
+ }
735
+ return children;
736
+ }
737
+ // ===========================================================================
738
+ // Metadata Operations
739
+ // ===========================================================================
740
+ /**
741
+ * Set a space-level metadata value.
742
+ * Metadata is stored in meta and hidden from AI operations.
743
+ */
744
+ setMetadata(key, value) {
745
+ if (!this._data.meta) {
746
+ this._data.meta = {};
747
+ }
748
+ this._data.meta[key] = value;
749
+ this.emit('metadataUpdated', { metadata: this._data.meta, source: 'local_user' });
750
+ // Fire-and-forget server call - errors trigger resync
751
+ this.graphqlClient.setSpaceMeta(this.id, this._data.meta, this._conversationId)
752
+ .catch((error) => {
753
+ console.error('[Space] Failed to set meta:', error);
754
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
755
+ });
756
+ }
757
+ /**
758
+ * Get a space-level metadata value.
759
+ */
760
+ getMetadata(key) {
761
+ return this._data.meta?.[key];
762
+ }
763
+ /**
764
+ * Get all space-level metadata.
765
+ */
766
+ getAllMetadata() {
767
+ return this._data.meta ?? {};
768
+ }
769
+ // ===========================================================================
770
+ // AI Operations
771
+ // ===========================================================================
772
+ /**
773
+ * Send a prompt to the AI agent for space manipulation.
774
+ * @returns The message from the AI and the list of objects that were created or modified
775
+ */
776
+ async prompt(prompt, options) {
777
+ const result = await this.graphqlClient.prompt(this._id, prompt, this._conversationId, options);
778
+ // Hydrate modified object IDs to actual objects (filter out deleted ones)
779
+ const objects = result.modifiedObjectIds
780
+ .map(id => this._data.objects[id]?.data)
781
+ .filter((obj) => obj !== undefined);
782
+ return {
783
+ message: result.message,
784
+ objects,
785
+ };
786
+ }
787
+ // ===========================================================================
788
+ // Collaboration
789
+ // ===========================================================================
790
+ /**
791
+ * List users with access to this space.
792
+ */
793
+ async listUsers() {
794
+ return this.graphqlClient.listSpaceUsers(this._id);
795
+ }
796
+ /**
797
+ * Add a user to this space with specified role.
798
+ */
799
+ async addUser(userId, role) {
800
+ return this.graphqlClient.addSpaceUser(this._id, userId, role);
801
+ }
802
+ /**
803
+ * Remove a user from this space.
804
+ */
805
+ async removeUser(userId) {
806
+ return this.graphqlClient.removeSpaceUser(this._id, userId);
807
+ }
808
+ // ===========================================================================
809
+ // Media Operations
810
+ // ===========================================================================
811
+ /**
812
+ * List all media files for this space.
813
+ */
814
+ async listMedia() {
815
+ return this.mediaClient.list(this._id);
816
+ }
817
+ /**
818
+ * Upload a file to this space. Returns the URL.
819
+ */
820
+ async uploadMedia(file) {
821
+ return this.mediaClient.upload(this._id, file);
822
+ }
823
+ /**
824
+ * Fetch any URL, returning headers and a blob() method (like fetch Response).
825
+ * Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
826
+ */
827
+ async fetchMedia(url) {
828
+ return this.mediaClient.fetch(this._id, url);
829
+ }
830
+ /**
831
+ * Delete a media file by URL.
832
+ */
833
+ async deleteMedia(url) {
834
+ return this.mediaClient.delete(this._id, url);
835
+ }
836
+ // ===========================================================================
837
+ // Low-level Operations
838
+ // ===========================================================================
839
+ /**
840
+ * Get the full space data.
841
+ * Use sparingly - prefer specific operations.
842
+ */
843
+ getData() {
844
+ return this._data;
845
+ }
846
+ // ===========================================================================
847
+ // Import/Export
848
+ // ===========================================================================
849
+ /**
850
+ * Export space data as JSON-LD.
851
+ * Returns a JSON-LD document with all objects and their relations.
852
+ * Space metadata and interaction history are not included.
853
+ */
854
+ export() {
855
+ return toJsonLd(this._data);
856
+ }
857
+ /**
858
+ * Import JSON-LD data into the space.
859
+ * Creates objects and links from the JSON-LD graph.
860
+ * Space must be empty (throws if objects exist).
861
+ */
862
+ async import(data) {
863
+ if (Object.keys(this._data.objects).length > 0) {
864
+ throw new Error('Cannot import into non-empty space. Create a new space or delete existing objects first.');
865
+ }
866
+ const parsed = fromJsonLd(data);
867
+ // Create all objects first
868
+ for (const obj of parsed.objects) {
869
+ await this.createObject({ data: obj.data });
870
+ }
871
+ // Then create all links
872
+ for (const obj of parsed.objects) {
873
+ for (const rel of obj.relations) {
874
+ await this.link(obj.id, rel.relation, rel.targetId);
875
+ }
876
+ }
877
+ }
878
+ /**
879
+ * Export space data and media as a zip archive.
880
+ * Media URLs are rewritten to relative paths within the archive.
881
+ * @returns A Blob containing the zip archive
882
+ */
883
+ async exportArchive() {
884
+ // Get JSON-LD export
885
+ const jsonld = this.export();
886
+ // Get all media in this space
887
+ const mediaList = await this.listMedia();
888
+ const mediaUrls = new Set(mediaList.map(m => m.url));
889
+ // Find which media URLs are actually used in the export
890
+ const allStrings = findAllStrings(jsonld);
891
+ const usedMediaUrls = [...allStrings].filter(s => mediaUrls.has(s));
892
+ // Build URL mapping and fetch media files
893
+ const urlMapping = new Map();
894
+ const files = {};
895
+ for (const url of usedMediaUrls) {
896
+ const mediaInfo = mediaList.find(m => m.url === url);
897
+ if (!mediaInfo)
898
+ continue;
899
+ try {
900
+ const response = await this.fetchMedia(url);
901
+ const blob = await response.blob();
902
+ const buffer = await blob.arrayBuffer();
903
+ // Determine filename: uuid + extension from content type
904
+ const ext = getExtensionFromContentType(response.contentType);
905
+ const filename = `${mediaInfo.uuid}${ext}`;
906
+ const relativePath = `media/${filename}`;
907
+ files[relativePath] = new Uint8Array(buffer);
908
+ urlMapping.set(url, relativePath);
909
+ }
910
+ catch (error) {
911
+ // Skip media that fails to fetch (e.g., 404)
912
+ console.warn(`[Space] Failed to fetch media for archive: ${url}`, error);
913
+ }
914
+ }
915
+ // Rewrite URLs in JSON-LD
916
+ const rewrittenJsonld = rewriteStrings(jsonld, urlMapping);
917
+ // Add data.json to the archive
918
+ const encoder = new TextEncoder();
919
+ files['data.json'] = encoder.encode(JSON.stringify(rewrittenJsonld, null, 2));
920
+ // Create zip archive
921
+ const zipped = zipSync(files);
922
+ return new Blob([zipped], { type: 'application/zip' });
923
+ }
924
+ /**
925
+ * Import from a zip archive containing data.json and media files.
926
+ * Space must be empty (throws if objects exist).
927
+ */
928
+ async importArchive(archive) {
929
+ if (Object.keys(this._data.objects).length > 0) {
930
+ throw new Error('Cannot import into non-empty space. Create a new space or delete existing objects first.');
931
+ }
932
+ // Read and unzip the archive
933
+ const buffer = await archive.arrayBuffer();
934
+ const unzipped = unzipSync(new Uint8Array(buffer));
935
+ // Parse data.json
936
+ const dataJsonBytes = unzipped['data.json'];
937
+ if (!dataJsonBytes) {
938
+ throw new Error('Invalid archive: missing data.json');
939
+ }
940
+ const decoder = new TextDecoder();
941
+ const jsonld = JSON.parse(decoder.decode(dataJsonBytes));
942
+ // Upload media files and build URL mapping
943
+ const urlMapping = new Map();
944
+ for (const [path, data] of Object.entries(unzipped)) {
945
+ if (!path.startsWith('media/'))
946
+ continue;
947
+ const contentType = getContentTypeFromFilename(path);
948
+ const blob = new Blob([data], { type: contentType });
949
+ const newUrl = await this.uploadMedia(blob);
950
+ urlMapping.set(path, newUrl);
951
+ }
952
+ // Rewrite URLs in JSON-LD
953
+ const rewrittenJsonld = rewriteStrings(jsonld, urlMapping);
954
+ // Import using existing logic
955
+ const parsed = fromJsonLd(rewrittenJsonld);
956
+ // Create all objects first
957
+ for (const obj of parsed.objects) {
958
+ await this.createObject({ data: obj.data });
959
+ }
960
+ // Then create all links
961
+ for (const obj of parsed.objects) {
962
+ for (const rel of obj.relations) {
963
+ await this.link(obj.id, rel.relation, rel.targetId);
964
+ }
965
+ }
966
+ }
967
+ // ===========================================================================
968
+ // Event Handlers (internal - handles space subscription events)
969
+ // ===========================================================================
970
+ /**
971
+ * Handle a space event from the subscription.
972
+ * @internal
973
+ */
974
+ handleSpaceEvent(event) {
975
+ switch (event.type) {
976
+ case 'space_patched':
977
+ if (event.patch) {
978
+ this.handleRemotePatch(event.patch, event.source);
979
+ }
980
+ break;
981
+ case 'space_changed':
982
+ // Full reload needed
983
+ void this.graphqlClient.getSpace(this._id).then(({ data }) => {
984
+ this._data = data;
985
+ this.emit('reset', { source: 'remote_user' });
986
+ });
987
+ break;
988
+ }
989
+ }
990
+ /**
991
+ * Check if a patch would actually change the current data.
992
+ * Used to deduplicate events when patches don't change anything (e.g., optimistic updates).
993
+ * @internal
994
+ */
995
+ didPatchChangeAnything(patch) {
996
+ for (const op of patch) {
997
+ const pathParts = op.path.split('/').filter(p => p);
998
+ let current = this._data;
999
+ for (const part of pathParts) {
1000
+ current = current?.[part];
1001
+ }
1002
+ if (op.op === 'remove' && current !== undefined)
1003
+ return true;
1004
+ if ((op.op === 'add' || op.op === 'replace') &&
1005
+ JSON.stringify(current) !== JSON.stringify(op.value))
1006
+ return true;
1007
+ }
1008
+ return false;
1009
+ }
1010
+ /**
1011
+ * Handle a patch event from another client.
1012
+ * @internal
1013
+ */
1014
+ handleRemotePatch(patch, source) {
1015
+ // Check if patch would change anything BEFORE applying
1016
+ const willChange = this.didPatchChangeAnything(patch);
1017
+ try {
1018
+ this._data = immutableJSONPatch(this._data, patch);
1019
+ }
1020
+ catch (error) {
1021
+ console.error('[Space] Failed to apply remote patch:', error);
1022
+ // Force resync on patch error
1023
+ this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
1024
+ return;
1025
+ }
1026
+ // Only emit events if something actually changed
1027
+ if (willChange) {
1028
+ const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
1029
+ this.emitSemanticEventsFromPatch(patch, changeSource);
1030
+ }
1031
+ }
1032
+ /**
1033
+ * Parse JSON patch operations and emit semantic events.
1034
+ * @internal
1035
+ */
1036
+ emitSemanticEventsFromPatch(patch, source) {
1037
+ // Track which objects have been updated (to avoid duplicate events)
1038
+ const updatedObjects = new Set();
1039
+ for (const op of patch) {
1040
+ const { path } = op;
1041
+ // Object operations: /objects/{objectId}/...
1042
+ if (path.startsWith('/objects/')) {
1043
+ const parts = path.split('/');
1044
+ const objectId = parts[2];
1045
+ if (parts.length === 3) {
1046
+ // /objects/{objectId} - full object add or remove
1047
+ if (op.op === 'add') {
1048
+ const entry = this._data.objects[objectId];
1049
+ if (entry) {
1050
+ this.emit('objectCreated', { objectId, object: entry.data, source });
1051
+ }
1052
+ }
1053
+ else if (op.op === 'remove') {
1054
+ this.emit('objectDeleted', { objectId, source });
1055
+ }
1056
+ }
1057
+ else if (parts[3] === 'data') {
1058
+ // /objects/{objectId}/data/... - data field update
1059
+ if (!updatedObjects.has(objectId)) {
1060
+ const entry = this._data.objects[objectId];
1061
+ if (entry) {
1062
+ this.emit('objectUpdated', { objectId, object: entry.data, source });
1063
+ updatedObjects.add(objectId);
1064
+ }
1065
+ }
1066
+ }
1067
+ else if (parts[3] === 'links') {
1068
+ // /objects/{objectId}/links/{relation} - links are arrays of target IDs
1069
+ if (parts.length === 5) {
1070
+ const relation = parts[4];
1071
+ if (op.op === 'add' || op.op === 'replace') {
1072
+ // New relation added or replaced - emit linked for all targets in the array
1073
+ const targets = this._data.objects[objectId]?.links[relation] ?? [];
1074
+ for (const targetId of targets) {
1075
+ this.emit('linked', { sourceId: objectId, relation, targetId, source });
1076
+ }
1077
+ }
1078
+ else if (op.op === 'remove') {
1079
+ // Relation removed - we don't have the old targets, so we can't emit individual unlinked events
1080
+ // The targets were already removed from local state by applyPatch before this runs
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ else if (path === '/meta' || path.startsWith('/meta/')) {
1086
+ this.emit('metadataUpdated', { metadata: this._data.meta, source });
1087
+ }
1088
+ // Conversation operations: /conversations/{conversationId} or /conversations/{conversationId}/...
1089
+ else if (path.startsWith('/conversations/')) {
1090
+ const parts = path.split('/');
1091
+ const conversationId = parts[2];
1092
+ if (conversationId) {
1093
+ this.emit('conversationUpdated', { conversationId, source });
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ // ===========================================================================
1099
+ // Private Methods
1100
+ // ===========================================================================
1101
+ async resyncFromServer(originalError) {
1102
+ console.warn('[Space] Resyncing from server after sync failure');
1103
+ try {
1104
+ const { data } = await this.graphqlClient.getSpace(this._id);
1105
+ this._data = data;
1106
+ // Clear history is now async but we don't need to wait for it during resync
1107
+ // (it's a server-side cleanup that can happen in background)
1108
+ this.clearHistory().catch((err) => {
1109
+ console.warn('[Space] Failed to clear history during resync:', err);
1110
+ });
1111
+ this.emit('syncError', originalError ?? new Error('Sync failed'));
1112
+ this.emit('reset', { source: 'system' });
1113
+ }
1114
+ catch (error) {
1115
+ console.error('[Space] Failed to resync from server:', error);
1116
+ // Still emit syncError with the original error
1117
+ this.emit('syncError', originalError ?? new Error('Sync failed'));
1118
+ }
1119
+ }
1120
+ }
1121
+ //# sourceMappingURL=space.js.map