@rool-dev/sdk 0.9.0-dev.a397f4d → 0.9.0-dev.bab1e95

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/dist/channel.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { EventEmitter } from './event-emitter.js';
2
- // 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
3
- const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
2
+ import { generateBasename, loc, normalizeLocation, parseLocation } from './locations.js';
3
+ // 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
4
+ const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
4
5
  export function generateEntityId() {
5
6
  let result = '';
6
7
  for (let i = 0; i < 6; i++) {
7
- result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
8
+ result += ENTITY_CHARS[Math.floor(Math.random() * ENTITY_CHARS.length)];
8
9
  }
9
10
  return result;
10
11
  }
@@ -40,6 +41,80 @@ function findDefaultLeaf(interactions) {
40
41
  }
41
42
  return best?.id;
42
43
  }
44
+ function attachmentBody(file, index) {
45
+ if (isFile(file)) {
46
+ return {
47
+ filename: safeAttachmentFilename(file.name, index, file.type),
48
+ contentType: file.type || 'application/octet-stream',
49
+ body: file,
50
+ };
51
+ }
52
+ if (isBlob(file)) {
53
+ const contentType = file.type || 'application/octet-stream';
54
+ return {
55
+ filename: safeAttachmentFilename(`attachment-${index + 1}`, index, contentType),
56
+ contentType,
57
+ body: file,
58
+ };
59
+ }
60
+ return {
61
+ filename: safeAttachmentFilename(`attachment-${index + 1}`, index, file.contentType),
62
+ contentType: file.contentType,
63
+ body: base64Body(file.data),
64
+ };
65
+ }
66
+ function isFile(value) {
67
+ return typeof File !== 'undefined' && value instanceof File;
68
+ }
69
+ function isBlob(value) {
70
+ return typeof Blob !== 'undefined' && value instanceof Blob;
71
+ }
72
+ function safeAttachmentFilename(name, index, contentType) {
73
+ const fallback = `attachment.${extensionForContentType(contentType)}`;
74
+ const leaf = name.split(/[/\\]/).pop() || fallback;
75
+ const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
76
+ const safe = cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
77
+ return `${index + 1}-${safe}`;
78
+ }
79
+ function extensionForContentType(contentType) {
80
+ if (contentType === 'image/png')
81
+ return 'png';
82
+ if (contentType === 'image/jpeg')
83
+ return 'jpg';
84
+ if (contentType === 'image/gif')
85
+ return 'gif';
86
+ if (contentType === 'image/webp')
87
+ return 'webp';
88
+ if (contentType === 'image/svg+xml')
89
+ return 'svg';
90
+ if (contentType === 'application/pdf')
91
+ return 'pdf';
92
+ if (contentType === 'text/markdown')
93
+ return 'md';
94
+ if (contentType === 'text/plain')
95
+ return 'txt';
96
+ if (contentType === 'text/csv')
97
+ return 'csv';
98
+ if (contentType === 'text/html')
99
+ return 'html';
100
+ if (contentType === 'application/json')
101
+ return 'json';
102
+ if (contentType === 'application/xml')
103
+ return 'xml';
104
+ return 'bin';
105
+ }
106
+ function base64Body(data) {
107
+ const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
108
+ if (typeof Buffer !== 'undefined') {
109
+ const buffer = Buffer.from(clean, 'base64');
110
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
111
+ }
112
+ const binary = atob(clean);
113
+ const bytes = new Uint8Array(binary.length);
114
+ for (let i = 0; i < binary.length; i++)
115
+ bytes[i] = binary.charCodeAt(i);
116
+ return bytes.buffer;
117
+ }
43
118
  // Default timeout for waiting on SSE object events (30 seconds)
44
119
  const OBJECT_COLLECT_TIMEOUT = 30000;
45
120
  /**
@@ -49,16 +124,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
49
124
  * at open time and cannot be changed. To use a different channel,
50
125
  * open a second one.
51
126
  *
52
- * Objects are fetched on demand from the server; only schema, metadata,
53
- * and the channel's own history are cached locally. Object changes
127
+ * Objects are addressed by location (`/space/<collection>/<basename>.json`).
128
+ * Only schema, metadata, the live object location list, and the channel's own
129
+ * history are cached locally. Object bodies are fetched on demand. Changes
54
130
  * arrive via SSE semantic events and are emitted as SDK events.
55
- *
56
- * Features:
57
- * - High-level object operations
58
- * - Built-in undo/redo with checkpoints
59
- * - Metadata management
60
- * - Event emission for state changes
61
- * - Real-time updates via space-specific subscription
62
131
  */
63
132
  export class RoolChannel extends EventEmitter {
64
133
  _id;
@@ -70,23 +139,24 @@ export class RoolChannel extends EventEmitter {
70
139
  _conversationId;
71
140
  _closed = false;
72
141
  graphqlClient;
73
- mediaClient;
142
+ restClient;
143
+ webdav;
74
144
  onCloseCallback;
75
145
  logger;
76
- // Local cache for bounded data (schema, metadata, own channel, object IDs, stats)
146
+ // Local cache for bounded data (schema, metadata, own channel, object locations, stats)
77
147
  _meta;
78
148
  _schema;
79
149
  _channel;
80
- _objectIds;
150
+ _objectLocations;
81
151
  _objectStats;
82
152
  // Active leaf per conversation (client-side tree cursor)
83
153
  _activeLeaves = new Map();
84
- // Object collection: tracks pending local mutations for dedup
85
- // Maps objectId → optimistic object data (for create/update) or null (for delete)
154
+ // Object collection: tracks pending local mutations (by location) for dedup
155
+ // Maps location → optimistic object (for create/update) or null (for delete)
86
156
  _pendingMutations = new Map();
87
- // Resolvers waiting for object data from SSE events
157
+ // Resolvers waiting for object data from SSE events, keyed by location
88
158
  _objectResolvers = new Map();
89
- // Buffer for object data that arrived before a collector was registered
159
+ // Buffer for object data that arrived before a collector was registered, keyed by location
90
160
  _objectBuffer = new Map();
91
161
  constructor(config) {
92
162
  super();
@@ -99,14 +169,15 @@ export class RoolChannel extends EventEmitter {
99
169
  this._channelId = config.channelId;
100
170
  this._conversationId = 'default';
101
171
  this.graphqlClient = config.graphqlClient;
102
- this.mediaClient = config.mediaClient;
172
+ this.restClient = config.restClient;
173
+ this.webdav = config.webdav;
103
174
  this.logger = config.logger;
104
175
  this.onCloseCallback = config.onClose;
105
176
  // Initialize local cache from server data
106
177
  this._meta = config.meta;
107
178
  this._schema = config.schema;
108
179
  this._channel = config.channel;
109
- this._objectIds = config.objectIds;
180
+ this._objectLocations = config.objectLocations;
110
181
  this._objectStats = new Map(Object.entries(config.objectStats));
111
182
  }
112
183
  /**
@@ -127,7 +198,7 @@ export class RoolChannel extends EventEmitter {
127
198
  return;
128
199
  this._meta = data.meta;
129
200
  this._schema = data.schema;
130
- this._objectIds = data.objectIds;
201
+ this._objectLocations = data.objectLocations;
131
202
  this._objectStats = new Map(Object.entries(data.objectStats));
132
203
  if (data.channel)
133
204
  this._channel = data.channel;
@@ -294,10 +365,6 @@ export class RoolChannel extends EventEmitter {
294
365
  }
295
366
  /**
296
367
  * Get a handle for a specific conversation within this channel.
297
- * The handle scopes AI and mutation operations to that conversation's
298
- * interaction history, while sharing the channel's single SSE connection.
299
- *
300
- * Conversations are auto-created on first interaction — no explicit create needed.
301
368
  */
302
369
  conversation(conversationId) {
303
370
  return new ConversationHandle(this, conversationId);
@@ -317,139 +384,113 @@ export class RoolChannel extends EventEmitter {
317
384
  }
318
385
  /**
319
386
  * Create a checkpoint of the current space state.
320
- * Checkpoints are space-wide and shared across channels and users.
321
- * @returns The checkpoint ID
322
387
  */
323
388
  async checkpoint(label = 'Change') {
324
389
  const result = await this.graphqlClient.checkpoint(this._id, label, this._channelId);
325
390
  return result.checkpointId;
326
391
  }
327
- /**
328
- * Check if undo is available for this space.
329
- */
392
+ /** Check if undo is available for this space. */
330
393
  async canUndo() {
331
394
  const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
332
395
  return status.canUndo;
333
396
  }
334
- /**
335
- * Check if redo is available for this space.
336
- */
397
+ /** Check if redo is available for this space. */
337
398
  async canRedo() {
338
399
  const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
339
400
  return status.canRedo;
340
401
  }
341
- /**
342
- * Restore the space to the most recent checkpoint.
343
- * @returns true if undo was performed
344
- */
402
+ /** Restore the space to the most recent checkpoint. */
345
403
  async undo() {
346
404
  const result = await this.graphqlClient.undo(this._id, this._channelId);
347
405
  return result.success;
348
406
  }
349
- /**
350
- * Reapply the most recently undone checkpoint.
351
- * Affects the entire space.
352
- * @returns true if redo was performed
353
- */
407
+ /** Reapply the most recently undone checkpoint. */
354
408
  async redo() {
355
409
  const result = await this.graphqlClient.redo(this._id, this._channelId);
356
410
  return result.success;
357
411
  }
358
- /**
359
- * Clear the space's checkpoint history.
360
- */
412
+ /** Clear the space's checkpoint history. */
361
413
  async clearHistory() {
362
414
  await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
363
415
  }
364
416
  /**
365
- * Get an object's data by ID.
366
- * Fetches from the server on each call.
417
+ * Get an object by location. Fetches from the server on each call.
418
+ *
419
+ * Accepts either the canonical form (`/space/<collection>/<basename>.json`)
420
+ * or the short form (`<collection>/<basename>`).
367
421
  */
368
- async getObject(objectId) {
369
- return this.graphqlClient.getObject(this._id, objectId);
422
+ async getObject(location) {
423
+ return this.graphqlClient.getObject(this._id, normalizeLocation(location));
370
424
  }
371
425
  /**
372
426
  * Get an object's stat (audit information).
373
- * Returns modification timestamp and author, or undefined if object not found.
427
+ * Returns the cached stat or undefined if not known.
374
428
  */
375
- stat(objectId) {
376
- return this._objectStats.get(objectId);
429
+ stat(location) {
430
+ return this._objectStats.get(normalizeLocation(location));
377
431
  }
378
432
  /**
379
433
  * Find objects using structured filters and/or natural language.
380
- *
381
- * `where` provides exact-match filtering — values must match literally (no placeholders or operators).
382
- * `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
383
- * constrain the data set before the AI sees it.
384
- *
385
- * @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
386
- * @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
387
- * @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
388
- * @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
389
- * @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
390
- * @param options.ephemeral - If true, the query won't be recorded in interaction history.
391
- * @returns The matching objects and a descriptive message.
392
434
  */
393
435
  async findObjects(options) {
394
436
  return this._findObjectsImpl(options, this._conversationId);
395
437
  }
396
438
  /** @internal */
397
439
  _findObjectsImpl(options, conversationId) {
398
- return this.graphqlClient.findObjects(this._id, options, this._channelId, conversationId);
440
+ const normalized = {
441
+ ...options,
442
+ locations: options.locations?.map(normalizeLocation),
443
+ };
444
+ return this.graphqlClient.findObjects(this._id, normalized, this._channelId, conversationId);
399
445
  }
400
446
  /**
401
- * Get all object IDs (sync, from local cache).
447
+ * Get all object locations (sync, from local cache).
402
448
  * The list is loaded on open and kept current via SSE events.
403
- * @param options.limit - Maximum number of IDs to return
404
- * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
405
449
  */
406
- getObjectIds(options) {
407
- let ids = this._objectIds;
450
+ getObjectLocations(options) {
451
+ let locs = this._objectLocations;
408
452
  if (options?.order === 'asc') {
409
- ids = [...ids].reverse();
453
+ locs = [...locs].reverse();
410
454
  }
411
455
  if (options?.limit !== undefined) {
412
- ids = ids.slice(0, options.limit);
456
+ locs = locs.slice(0, options.limit);
413
457
  }
414
- return ids;
458
+ return locs;
415
459
  }
416
460
  /**
417
- * Create a new object with optional AI generation.
418
- * @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.
461
+ * Create a new object in the given collection.
462
+ *
463
+ * @param collection - The collection (must exist in the schema)
464
+ * @param body - Object body fields. Use `{{placeholder}}` for AI-generated content.
465
+ * Fields prefixed with `_` are hidden from AI. Must not contain
466
+ * `id` or `type` (identity lives on the location).
467
+ * @param options.basename - Specific basename to use. If omitted, the SDK generates a random one.
419
468
  * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
420
- * @returns The created object (with AI-filled content) and message
469
+ * @returns The created object and a status message.
421
470
  */
422
- async createObject(options) {
423
- return this._createObjectImpl(options, this._conversationId);
471
+ async createObject(collection, body, options) {
472
+ return this._createObjectImpl(collection, body, options, this._conversationId);
424
473
  }
425
474
  /** @internal */
426
- async _createObjectImpl(options, conversationId) {
427
- const { data, ephemeral } = options;
428
- const basename = typeof data.id === 'string' ? data.id : generateEntityId();
429
- const type = data.type;
430
- if (!/^[a-zA-Z0-9_-]+$/.test(basename)) {
431
- throw new Error(`Invalid object ID "${basename}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
432
- }
433
- if (typeof type !== 'string' || !type) {
434
- throw new Error('createObject: data.type is required');
475
+ async _createObjectImpl(collection, body, options, conversationId) {
476
+ if ('id' in body || 'type' in body) {
477
+ throw new Error('createObject: body must not contain `id` or `type` identity is the location.');
435
478
  }
436
- // Server's canonical id is "<type>/<basename>" predict it locally so the
437
- // optimistic event id matches the SSE echo, no dedup workaround needed.
438
- const objectId = `${type}/${basename}`;
439
- const dataForWire = { ...data, id: basename }; // server expects basename in data.id
440
- const optimisticObject = { ...data, id: objectId }; // SDK tracks path-form
441
- this._pendingMutations.set(objectId, optimisticObject);
442
- this.emit('objectCreated', { objectId, object: optimisticObject, source: 'local_user' });
479
+ const basename = options?.basename ?? generateBasename();
480
+ const location = loc(collection, basename);
481
+ const optimistic = { location, collection, basename, body };
482
+ this._pendingMutations.set(location, optimistic);
483
+ this.emit('objectCreated', { location, object: optimistic, source: 'local_user' });
443
484
  try {
444
485
  const interactionId = generateEntityId();
445
- const { message } = await this.graphqlClient.createObject(this.id, dataForWire, this._channelId, conversationId, interactionId, ephemeral);
446
- const object = await this._collectObject(objectId);
447
- return { object, message };
486
+ const { message, object } = await this.graphqlClient.createObject(this._id, location, body, this._channelId, conversationId, interactionId, { ephemeral: options?.ephemeral, parentInteractionId: options?.parentInteractionId });
487
+ const fresh = object ?? await this._collectObject(location);
488
+ return { object: fresh, message };
448
489
  }
449
490
  catch (error) {
450
491
  this.logger.error('[RoolChannel] Failed to create object:', error);
451
- this._pendingMutations.delete(objectId);
452
- this._cancelCollector(objectId);
492
+ this._pendingMutations.delete(location);
493
+ this._cancelCollector(location);
453
494
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
454
495
  this.emit('reset', { source: 'system' });
455
496
  throw error;
@@ -457,98 +498,141 @@ export class RoolChannel extends EventEmitter {
457
498
  }
458
499
  /**
459
500
  * Update an existing object.
460
- * @param objectId - The ID of the object to update
461
- * @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.
462
- * @param options.prompt - AI prompt for content editing (optional).
501
+ *
502
+ * @param location - The object's location (canonical or short form)
503
+ * @param options.data - Fields to add or update. Pass `null` to delete a field. Use `{{placeholder}}` for AI-generated content.
504
+ * @param options.prompt - AI prompt to drive the update.
463
505
  * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
464
- * @returns The updated object (with AI-filled content) and message
465
506
  */
466
- async updateObject(objectId, options) {
467
- return this._updateObjectImpl(objectId, options, this._conversationId);
507
+ async updateObject(location, options) {
508
+ return this._updateObjectImpl(location, options, this._conversationId);
468
509
  }
469
510
  /** @internal */
470
- async _updateObjectImpl(objectId, options, conversationId) {
471
- const { data, ephemeral } = options;
472
- // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
473
- if (data?.id !== undefined && data.id !== null) {
474
- throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
475
- }
476
- if (data && ('id' in data)) {
477
- throw new Error('Cannot delete id field. The id field is immutable after creation.');
511
+ async _updateObjectImpl(location, options, conversationId) {
512
+ const canonical = normalizeLocation(location);
513
+ const { data } = options;
514
+ if (data && ('id' in data || 'type' in data)) {
515
+ throw new Error('updateObject: data must not contain `id` or `type`. Use moveObject to change identity.');
478
516
  }
479
- // Normalize undefined to null (for JSON serialization) and build server data
480
- let serverData;
517
+ // Normalize undefined to null (for JSON serialization) and build server patch
518
+ let serverPatch;
481
519
  if (data) {
482
- serverData = {};
520
+ serverPatch = {};
483
521
  for (const [key, value] of Object.entries(data)) {
484
- // Convert undefined to null for wire protocol
485
- serverData[key] = value === undefined ? null : value;
522
+ serverPatch[key] = value === undefined ? null : value;
486
523
  }
487
524
  }
488
525
  // Emit optimistic event if we have data changes
489
526
  if (data) {
490
- // Build optimistic object (best effort — we may not have the current state)
491
- const optimistic = { id: objectId, ...data };
492
- this._pendingMutations.set(objectId, optimistic);
493
- this.emit('objectUpdated', { objectId, object: optimistic, source: 'local_user' });
527
+ const { collection, basename } = parseLocation(canonical);
528
+ const optimistic = { location: canonical, collection, basename, body: data };
529
+ this._pendingMutations.set(canonical, optimistic);
530
+ this.emit('objectUpdated', { location: canonical, object: optimistic, source: 'local_user' });
494
531
  }
495
532
  try {
496
533
  const interactionId = generateEntityId();
497
- const { message } = await this.graphqlClient.updateObject(this.id, objectId, this._channelId, conversationId, interactionId, serverData, options.prompt, ephemeral);
498
- const object = await this._collectObject(objectId);
499
- return { object, message };
534
+ const { message, object } = await this.graphqlClient.updateObject(this._id, canonical, this._channelId, conversationId, interactionId, {
535
+ patch: serverPatch,
536
+ prompt: options.prompt,
537
+ ephemeral: options.ephemeral,
538
+ parentInteractionId: options.parentInteractionId,
539
+ });
540
+ const fresh = object ?? await this._collectObject(canonical);
541
+ return { object: fresh, message };
500
542
  }
501
543
  catch (error) {
502
544
  this.logger.error('[RoolChannel] Failed to update object:', error);
503
- this._pendingMutations.delete(objectId);
504
- this._cancelCollector(objectId);
545
+ this._pendingMutations.delete(canonical);
546
+ this._cancelCollector(canonical);
547
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
548
+ this.emit('reset', { source: 'system' });
549
+ throw error;
550
+ }
551
+ }
552
+ /**
553
+ * Move (rename or relocate) an object to a new location.
554
+ * Use this to rename, change collection, or atomically rewrite the body.
555
+ *
556
+ * @param from - Current location
557
+ * @param to - New location
558
+ * @param options.body - Replace the body atomically as part of the move.
559
+ * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
560
+ */
561
+ async moveObject(from, to, options) {
562
+ return this._moveObjectImpl(from, to, options, this._conversationId);
563
+ }
564
+ /** @internal */
565
+ async _moveObjectImpl(from, to, options, conversationId) {
566
+ const fromLoc = normalizeLocation(from);
567
+ const toLoc = normalizeLocation(to);
568
+ if (options?.body && ('id' in options.body || 'type' in options.body)) {
569
+ throw new Error('moveObject: body must not contain `id` or `type` — identity is the location.');
570
+ }
571
+ // Optimistic event — emit move so listeners can update keys
572
+ const { collection, basename } = parseLocation(toLoc);
573
+ const optimistic = {
574
+ location: toLoc,
575
+ collection,
576
+ basename,
577
+ body: options?.body ?? {},
578
+ };
579
+ this._pendingMutations.set(toLoc, optimistic);
580
+ this.emit('objectMoved', { from: fromLoc, to: toLoc, object: optimistic, source: 'local_user' });
581
+ try {
582
+ const interactionId = generateEntityId();
583
+ const { message, object } = await this.graphqlClient.moveObject(this._id, fromLoc, toLoc, this._channelId, conversationId, interactionId, {
584
+ body: options?.body,
585
+ ephemeral: options?.ephemeral,
586
+ parentInteractionId: options?.parentInteractionId,
587
+ });
588
+ const fresh = object ?? await this._collectObject(toLoc);
589
+ return { object: fresh, message };
590
+ }
591
+ catch (error) {
592
+ this.logger.error('[RoolChannel] Failed to move object:', error);
593
+ this._pendingMutations.delete(toLoc);
594
+ this._cancelCollector(toLoc);
505
595
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
506
596
  this.emit('reset', { source: 'system' });
507
597
  throw error;
508
598
  }
509
599
  }
510
600
  /**
511
- * Delete objects by IDs.
512
- * Other objects that reference deleted objects via data fields will retain stale ref values.
601
+ * Delete objects by location.
602
+ * Other objects that reference deleted objects will retain stale ref values.
513
603
  */
514
- async deleteObjects(objectIds) {
515
- return this._deleteObjectsImpl(objectIds, this._conversationId);
604
+ async deleteObjects(locations) {
605
+ return this._deleteObjectsImpl(locations, this._conversationId);
516
606
  }
517
607
  /** @internal */
518
- async _deleteObjectsImpl(objectIds, conversationId) {
519
- if (objectIds.length === 0)
608
+ async _deleteObjectsImpl(locations, conversationId) {
609
+ if (locations.length === 0)
520
610
  return;
611
+ const canonical = locations.map(normalizeLocation);
521
612
  // Track for dedup and emit optimistic events
522
- for (const objectId of objectIds) {
523
- this._pendingMutations.set(objectId, null);
524
- this.emit('objectDeleted', { objectId, source: 'local_user' });
613
+ for (const location of canonical) {
614
+ this._pendingMutations.set(location, null);
615
+ this.emit('objectDeleted', { location, source: 'local_user' });
525
616
  }
526
617
  try {
527
- await this.graphqlClient.deleteObjects(this.id, objectIds, this._channelId, conversationId);
618
+ const interactionId = generateEntityId();
619
+ await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
528
620
  }
529
621
  catch (error) {
530
622
  this.logger.error('[RoolChannel] Failed to delete objects:', error);
531
- for (const objectId of objectIds) {
532
- this._pendingMutations.delete(objectId);
623
+ for (const location of canonical) {
624
+ this._pendingMutations.delete(location);
533
625
  }
534
626
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
535
627
  this.emit('reset', { source: 'system' });
536
628
  throw error;
537
629
  }
538
630
  }
539
- /**
540
- * Get the current schema for this space.
541
- * Returns a map of collection names to their definitions.
542
- */
631
+ /** Get the current schema for this space. */
543
632
  getSchema() {
544
633
  return this._schema;
545
634
  }
546
- /**
547
- * Create a new collection schema.
548
- * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
549
- * @param fields - Field definitions for the collection
550
- * @returns The created CollectionDef
551
- */
635
+ /** Create a new collection schema. */
552
636
  async createCollection(name, fields) {
553
637
  return this._createCollectionImpl(name, fields, this._conversationId);
554
638
  }
@@ -569,12 +653,7 @@ export class RoolChannel extends EventEmitter {
569
653
  throw error;
570
654
  }
571
655
  }
572
- /**
573
- * Alter an existing collection schema, replacing its field definitions.
574
- * @param name - Name of the collection to alter
575
- * @param fields - New field definitions (replaces all existing fields)
576
- * @returns The updated CollectionDef
577
- */
656
+ /** Alter an existing collection schema, replacing its field definitions. */
578
657
  async alterCollection(name, fields) {
579
658
  return this._alterCollectionImpl(name, fields, this._conversationId);
580
659
  }
@@ -584,7 +663,6 @@ export class RoolChannel extends EventEmitter {
584
663
  throw new Error(`Collection "${name}" not found`);
585
664
  }
586
665
  const previous = this._schema[name];
587
- // Optimistic local update
588
666
  this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
589
667
  try {
590
668
  return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
@@ -595,10 +673,7 @@ export class RoolChannel extends EventEmitter {
595
673
  throw error;
596
674
  }
597
675
  }
598
- /**
599
- * Drop a collection schema.
600
- * @param name - Name of the collection to drop
601
- */
676
+ /** Drop a collection schema. */
602
677
  async dropCollection(name) {
603
678
  return this._dropCollectionImpl(name, this._conversationId);
604
679
  }
@@ -608,7 +683,6 @@ export class RoolChannel extends EventEmitter {
608
683
  throw new Error(`Collection "${name}" not found`);
609
684
  }
610
685
  const previous = this._schema[name];
611
- // Optimistic local update
612
686
  delete this._schema[name];
613
687
  try {
614
688
  await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
@@ -621,7 +695,6 @@ export class RoolChannel extends EventEmitter {
621
695
  }
622
696
  /**
623
697
  * Get the system instruction for the current conversation.
624
- * Returns undefined if no system instruction is set.
625
698
  */
626
699
  getSystemInstruction() {
627
700
  return this._getSystemInstructionImpl(this._conversationId);
@@ -630,16 +703,12 @@ export class RoolChannel extends EventEmitter {
630
703
  _getSystemInstructionImpl(conversationId) {
631
704
  return this._channel?.conversations[conversationId]?.systemInstruction;
632
705
  }
633
- /**
634
- * Set the system instruction for the current conversation.
635
- * Pass null to clear the instruction.
636
- */
706
+ /** Set the system instruction for the current conversation. */
637
707
  async setSystemInstruction(instruction) {
638
708
  return this._setSystemInstructionImpl(instruction, this._conversationId);
639
709
  }
640
710
  /** @internal */
641
711
  async _setSystemInstructionImpl(instruction, conversationId) {
642
- // Optimistic local update
643
712
  this._ensureConversationImpl(conversationId);
644
713
  const conv = this._channel.conversations[conversationId];
645
714
  const previousInstruction = conv.systemInstruction;
@@ -649,7 +718,6 @@ export class RoolChannel extends EventEmitter {
649
718
  else {
650
719
  conv.systemInstruction = instruction;
651
720
  }
652
- // Emit events for backward compat and new API
653
721
  this.emit('conversationUpdated', {
654
722
  conversationId,
655
723
  channelId: this._channelId,
@@ -661,13 +729,11 @@ export class RoolChannel extends EventEmitter {
661
729
  source: 'local_user',
662
730
  });
663
731
  }
664
- // Call server
665
732
  try {
666
733
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
667
734
  }
668
735
  catch (error) {
669
736
  this.logger.error('[RoolChannel] Failed to set system instruction:', error);
670
- // Rollback
671
737
  if (previousInstruction === undefined) {
672
738
  delete conv.systemInstruction;
673
739
  }
@@ -677,15 +743,12 @@ export class RoolChannel extends EventEmitter {
677
743
  throw error;
678
744
  }
679
745
  }
680
- /**
681
- * Rename the current conversation.
682
- */
746
+ /** Rename the current conversation. */
683
747
  async renameConversation(name) {
684
748
  return this._renameConversationImpl(name, this._conversationId);
685
749
  }
686
750
  /** @internal */
687
751
  async _renameConversationImpl(name, conversationId) {
688
- // Optimistic local update
689
752
  this._ensureConversationImpl(conversationId);
690
753
  const conv = this._channel.conversations[conversationId];
691
754
  const previousName = conv.name;
@@ -701,21 +764,16 @@ export class RoolChannel extends EventEmitter {
701
764
  source: 'local_user',
702
765
  });
703
766
  }
704
- // Call server
705
767
  try {
706
768
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
707
769
  }
708
770
  catch (error) {
709
771
  this.logger.error('[RoolChannel] Failed to rename conversation:', error);
710
- // Rollback
711
772
  conv.name = previousName;
712
773
  throw error;
713
774
  }
714
775
  }
715
- /**
716
- * Ensure a conversation exists in the local channel cache.
717
- * @internal
718
- */
776
+ /** @internal */
719
777
  _ensureConversationImpl(conversationId) {
720
778
  if (!this._channel) {
721
779
  this._channel = {
@@ -732,10 +790,7 @@ export class RoolChannel extends EventEmitter {
732
790
  };
733
791
  }
734
792
  }
735
- /**
736
- * Set a space-level metadata value.
737
- * Metadata is stored in meta and hidden from AI operations.
738
- */
793
+ /** Set a space-level metadata value. */
739
794
  setMetadata(key, value) {
740
795
  this._setMetadataImpl(key, value, this._conversationId);
741
796
  }
@@ -744,50 +799,43 @@ export class RoolChannel extends EventEmitter {
744
799
  this._meta[key] = value;
745
800
  this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
746
801
  // Fire-and-forget server call
747
- this.graphqlClient.setSpaceMeta(this.id, this._meta, this._channelId, conversationId)
802
+ this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
748
803
  .catch((error) => {
749
804
  this.logger.error('[RoolChannel] Failed to set meta:', error);
750
805
  });
751
806
  }
752
- /**
753
- * Get a space-level metadata value.
754
- */
807
+ /** Get a space-level metadata value. */
755
808
  getMetadata(key) {
756
809
  return this._meta[key];
757
810
  }
758
- /**
759
- * Get all space-level metadata.
760
- */
811
+ /** Get all space-level metadata. */
761
812
  getAllMetadata() {
762
813
  return this._meta;
763
814
  }
764
815
  /**
765
816
  * Send a prompt to the AI agent for space manipulation.
766
- * @returns The message from the AI and the list of objects that were created or modified
817
+ * @returns The message from the AI and the list of objects that were created or modified.
767
818
  */
768
819
  async prompt(prompt, options) {
769
820
  return this._promptImpl(prompt, options, this._conversationId);
770
821
  }
771
822
  /** @internal */
772
823
  async _promptImpl(prompt, options, conversationId) {
773
- // Upload attachments via media endpoint, then send URLs to the server
774
- const { attachments, parentInteractionId: explicitParent, signal, ...rest } = options ?? {};
775
- let attachmentUrls;
824
+ const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
825
+ const interactionId = generateEntityId();
826
+ let attachmentRefs;
776
827
  if (attachments?.length) {
777
- attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
828
+ attachmentRefs = await Promise.all(attachments.map((file, index) => this.uploadAttachment(file, interactionId, index)));
778
829
  }
779
830
  // Auto-continue from active leaf if no explicit parent provided
780
831
  const parentInteractionId = explicitParent !== undefined
781
832
  ? explicitParent
782
833
  : (this._getActiveLeafImpl(conversationId) ?? null);
783
- const interactionId = generateEntityId();
784
834
  // Optimistically set active leaf before the server call.
785
835
  this._activeLeaves.set(conversationId, interactionId);
786
836
  let onAbort;
787
837
  if (signal) {
788
838
  if (signal.aborted) {
789
- // Caller aborted before we even started; fire-and-forget the stop so
790
- // the server-side prompt (about to start) is cancelled too.
791
839
  this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
792
840
  }
793
841
  else {
@@ -799,29 +847,33 @@ export class RoolChannel extends EventEmitter {
799
847
  }
800
848
  let result;
801
849
  try {
802
- result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, { ...rest, attachmentUrls, interactionId, parentInteractionId });
850
+ result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
851
+ ...rest,
852
+ locations: locations?.map(normalizeLocation),
853
+ attachmentRefs,
854
+ interactionId,
855
+ parentInteractionId,
856
+ });
803
857
  }
804
858
  finally {
805
859
  if (onAbort)
806
860
  signal.removeEventListener('abort', onAbort);
807
861
  }
808
862
  // Collect modified objects — they arrive via SSE events during/after the mutation.
809
- // Try collecting from buffer first, then fetch any missing from server.
810
863
  const objects = [];
811
864
  const missing = [];
812
- for (const id of result.modifiedObjectIds) {
813
- const buffered = this._objectBuffer.get(id);
865
+ for (const location of result.modifiedObjectLocations) {
866
+ const buffered = this._objectBuffer.get(location);
814
867
  if (buffered) {
815
- this._objectBuffer.delete(id);
868
+ this._objectBuffer.delete(location);
816
869
  objects.push(buffered);
817
870
  }
818
871
  else {
819
- missing.push(id);
872
+ missing.push(location);
820
873
  }
821
874
  }
822
- // Fetch any objects not yet received via SSE
823
875
  if (missing.length > 0) {
824
- const fetched = await Promise.all(missing.map(id => this.graphqlClient.getObject(this._id, id)));
876
+ const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
825
877
  for (const obj of fetched) {
826
878
  if (obj)
827
879
  objects.push(obj);
@@ -832,119 +884,89 @@ export class RoolChannel extends EventEmitter {
832
884
  objects,
833
885
  };
834
886
  }
835
- /**
836
- * Rename this channel.
837
- */
887
+ /** Rename this channel. */
838
888
  async rename(newName) {
839
- // Optimistic local update
840
889
  const previousName = this._channel?.name;
841
890
  if (this._channel) {
842
891
  this._channel.name = newName;
843
892
  }
844
893
  this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
845
- // Call server
846
894
  try {
847
895
  await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
848
896
  }
849
897
  catch (error) {
850
898
  this.logger.error('[RoolChannel] Failed to rename channel:', error);
851
- // Rollback
852
899
  if (this._channel) {
853
900
  this._channel.name = previousName;
854
901
  }
855
902
  throw error;
856
903
  }
857
904
  }
858
- /**
859
- * List all media files for this space.
860
- */
861
- async listMedia() {
862
- return this.mediaClient.list(this._id);
863
- }
864
- /**
865
- * Upload a file to this space. Returns the URL.
866
- */
867
- async uploadMedia(file) {
868
- return this.mediaClient.upload(this._id, file);
869
- }
870
- /**
871
- * Fetch any URL, returning headers and a blob() method (like fetch Response).
872
- * Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
873
- * Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
874
- */
875
- async fetchMedia(url, options) {
876
- return this.mediaClient.fetch(this._id, url, options);
877
- }
878
- /**
879
- * Delete a media file by URL.
880
- */
881
- async deleteMedia(url) {
882
- return this.mediaClient.delete(this._id, url);
883
- }
884
905
  /**
885
906
  * Fetch an external URL via the server proxy, bypassing CORS restrictions.
886
- * Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
887
- *
888
- * @param url - The URL to fetch
889
- * @param init - Optional method, headers, and body
890
- * @returns The proxied Response
891
907
  */
892
908
  async fetch(url, init) {
893
- return this.mediaClient.proxyFetch(this._id, url, init);
909
+ return this.restClient.proxyFetch(this._id, url, init);
910
+ }
911
+ async uploadAttachment(file, interactionId, index) {
912
+ await this.ensureCollection('attachments');
913
+ const directory = `attachments/${interactionId}`;
914
+ await this.ensureCollection(directory);
915
+ const attachment = attachmentBody(file, index);
916
+ const path = `${directory}/${attachment.filename}`;
917
+ await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
918
+ return this.webdav.ref(path);
919
+ }
920
+ async ensureCollection(path) {
921
+ const response = await this.webdav.request('MKCOL', path, { collection: true });
922
+ if (response.status === 201 || response.status === 405)
923
+ return;
924
+ throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
894
925
  }
895
926
  /**
896
927
  * Register a collector that resolves when the object arrives via SSE.
897
- * If the object is already in the buffer (arrived before collector), resolves immediately.
898
928
  * @internal
899
929
  */
900
- _collectObject(objectId) {
930
+ _collectObject(location) {
901
931
  return new Promise((resolve, reject) => {
902
- // Check buffer first — SSE event may have arrived before the HTTP response
903
- const buffered = this._objectBuffer.get(objectId);
932
+ const buffered = this._objectBuffer.get(location);
904
933
  if (buffered) {
905
- this._objectBuffer.delete(objectId);
934
+ this._objectBuffer.delete(location);
906
935
  resolve(buffered);
907
936
  return;
908
937
  }
909
938
  const timer = setTimeout(() => {
910
- this._objectResolvers.delete(objectId);
939
+ this._objectResolvers.delete(location);
911
940
  // Fallback: try to fetch from server
912
- this.graphqlClient.getObject(this._id, objectId).then(obj => {
941
+ this.graphqlClient.getObject(this._id, location).then(obj => {
913
942
  if (obj) {
914
943
  resolve(obj);
915
944
  }
916
945
  else {
917
- reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
946
+ reject(new Error(`Timeout waiting for object ${location} from SSE`));
918
947
  }
919
948
  }).catch(reject);
920
949
  }, OBJECT_COLLECT_TIMEOUT);
921
- this._objectResolvers.set(objectId, (obj) => {
950
+ this._objectResolvers.set(location, (obj) => {
922
951
  clearTimeout(timer);
923
952
  resolve(obj);
924
953
  });
925
954
  });
926
955
  }
927
- /**
928
- * Cancel a pending object collector (e.g., on mutation error).
929
- * @internal
930
- */
931
- _cancelCollector(objectId) {
932
- this._objectResolvers.delete(objectId);
933
- this._objectBuffer.delete(objectId);
956
+ /** @internal */
957
+ _cancelCollector(location) {
958
+ this._objectResolvers.delete(location);
959
+ this._objectBuffer.delete(location);
934
960
  }
935
- /**
936
- * Deliver an object to a pending collector, or buffer it for later collection.
937
- * @internal
938
- */
939
- _deliverObject(objectId, object) {
940
- const resolver = this._objectResolvers.get(objectId);
961
+ /** @internal */
962
+ _deliverObject(location, object) {
963
+ const resolver = this._objectResolvers.get(location);
941
964
  if (resolver) {
942
965
  resolver(object);
943
- this._objectResolvers.delete(objectId);
966
+ this._objectResolvers.delete(location);
944
967
  }
945
968
  else {
946
- // Buffer for prompt() or late collectors
947
- this._objectBuffer.set(objectId, object);
969
+ this._objectBuffer.set(location, object);
948
970
  }
949
971
  }
950
972
  /**
@@ -952,7 +974,6 @@ export class RoolChannel extends EventEmitter {
952
974
  * @internal
953
975
  */
954
976
  handleChannelEvent(event) {
955
- // Ignore events after close - the channel is being torn down
956
977
  if (this._closed)
957
978
  return;
958
979
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
@@ -961,23 +982,31 @@ export class RoolChannel extends EventEmitter {
961
982
  // Resync is handled by the client via _applyResyncData.
962
983
  break;
963
984
  case 'object_created':
964
- if (event.objectId && event.object) {
985
+ if (event.location && event.object) {
965
986
  if (event.objectStat)
966
- this._objectStats.set(event.objectId, event.objectStat);
967
- this._handleObjectCreated(event.objectId, event.object, changeSource);
987
+ this._objectStats.set(event.location, event.objectStat);
988
+ this._handleObjectCreated(event.location, event.object, changeSource);
968
989
  }
969
990
  break;
970
991
  case 'object_updated':
971
- if (event.objectId && event.object) {
992
+ if (event.location && event.object) {
972
993
  if (event.objectStat)
973
- this._objectStats.set(event.objectId, event.objectStat);
974
- this._handleObjectUpdated(event.objectId, event.object, changeSource);
994
+ this._objectStats.set(event.location, event.objectStat);
995
+ this._handleObjectUpdated(event.location, event.object, changeSource);
975
996
  }
976
997
  break;
977
998
  case 'object_deleted':
978
- if (event.objectId) {
979
- this._objectStats.delete(event.objectId);
980
- this._handleObjectDeleted(event.objectId, changeSource);
999
+ if (event.location) {
1000
+ this._objectStats.delete(event.location);
1001
+ this._handleObjectDeleted(event.location, changeSource);
1002
+ }
1003
+ break;
1004
+ case 'object_moved':
1005
+ if (event.from && event.to && event.object) {
1006
+ this._objectStats.delete(event.from);
1007
+ if (event.objectStat)
1008
+ this._objectStats.set(event.to, event.objectStat);
1009
+ this._handleObjectMoved(event.from, event.to, event.object, changeSource);
981
1010
  }
982
1011
  break;
983
1012
  case 'schema_updated':
@@ -993,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
993
1022
  }
994
1023
  break;
995
1024
  case 'channel_updated':
996
- // Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
997
1025
  if (event.channelId === this._channelId && event.channel) {
998
1026
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
999
1027
  this._channel = event.channel;
@@ -1003,7 +1031,6 @@ export class RoolChannel extends EventEmitter {
1003
1031
  }
1004
1032
  break;
1005
1033
  case 'conversation_updated':
1006
- // Only update if it's our channel
1007
1034
  if (event.channelId === this._channelId && event.conversationId) {
1008
1035
  if (!this._channel) {
1009
1036
  this._channel = {
@@ -1014,17 +1041,13 @@ export class RoolChannel extends EventEmitter {
1014
1041
  }
1015
1042
  const prev = this._channel.conversations[event.conversationId];
1016
1043
  if (event.conversation) {
1017
- // Update or create conversation in local cache
1018
1044
  this._channel.conversations[event.conversationId] = event.conversation;
1019
1045
  }
1020
1046
  else {
1021
- // Conversation was deleted
1022
1047
  delete this._channel.conversations[event.conversationId];
1023
1048
  }
1024
- // Skip emit if data is unchanged (e.g. echo of our own optimistic update)
1025
1049
  if (JSON.stringify(prev) === JSON.stringify(event.conversation))
1026
1050
  break;
1027
- // Auto-advance active leaf if someone continued our current branch
1028
1051
  if (event.conversation && !Array.isArray(event.conversation.interactions)) {
1029
1052
  const currentLeaf = this._getActiveLeafImpl(event.conversationId);
1030
1053
  if (currentLeaf) {
@@ -1036,13 +1059,11 @@ export class RoolChannel extends EventEmitter {
1036
1059
  }
1037
1060
  }
1038
1061
  }
1039
- // Emit the new conversationUpdated event
1040
1062
  this.emit('conversationUpdated', {
1041
1063
  conversationId: event.conversationId,
1042
1064
  channelId: event.channelId,
1043
1065
  source: changeSource,
1044
1066
  });
1045
- // Backward compat: also emit channelUpdated when the active conversation updates
1046
1067
  if (event.conversationId === this._conversationId) {
1047
1068
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1048
1069
  }
@@ -1053,87 +1074,75 @@ export class RoolChannel extends EventEmitter {
1053
1074
  break;
1054
1075
  }
1055
1076
  }
1056
- /**
1057
- * Handle an object_created SSE event.
1058
- * Deduplicates against optimistic local creates.
1059
- * @internal
1060
- */
1061
- _handleObjectCreated(objectId, object, source) {
1062
- // Deliver to any pending collector (for mutation return values)
1063
- this._deliverObject(objectId, object);
1064
- // Maintain local ID list — prepend (most recently modified first)
1065
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1066
- const pending = this._pendingMutations.get(objectId);
1077
+ /** @internal */
1078
+ _handleObjectCreated(location, object, source) {
1079
+ this._deliverObject(location, object);
1080
+ // Maintain local location list — prepend (most recently modified first)
1081
+ this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1082
+ const pending = this._pendingMutations.get(location);
1067
1083
  if (pending !== undefined) {
1068
- // This is our own mutation echoed back
1069
- this._pendingMutations.delete(objectId);
1084
+ this._pendingMutations.delete(location);
1070
1085
  if (pending !== null) {
1071
- // It was a create — already emitted objectCreated optimistically.
1086
+ // Already emitted objectCreated optimistically.
1072
1087
  // Emit objectUpdated only if AI resolved placeholders (data changed).
1073
1088
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1074
- this.emit('objectUpdated', { objectId, object, source });
1089
+ this.emit('objectUpdated', { location, object, source });
1075
1090
  }
1076
1091
  }
1077
1092
  }
1078
1093
  else {
1079
- // Remote event emit normally
1080
- this.emit('objectCreated', { objectId, object, source });
1094
+ this.emit('objectCreated', { location, object, source });
1081
1095
  }
1082
1096
  }
1083
- /**
1084
- * Handle an object_updated SSE event.
1085
- * Deduplicates against optimistic local updates.
1086
- * @internal
1087
- */
1088
- _handleObjectUpdated(objectId, object, source) {
1089
- // Deliver to any pending collector
1090
- this._deliverObject(objectId, object);
1091
- // Maintain local ID list — move to front (most recently modified)
1092
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1093
- const pending = this._pendingMutations.get(objectId);
1097
+ /** @internal */
1098
+ _handleObjectUpdated(location, object, source) {
1099
+ this._deliverObject(location, object);
1100
+ this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1101
+ const pending = this._pendingMutations.get(location);
1094
1102
  if (pending !== undefined) {
1095
- // This is our own mutation echoed back
1096
- this._pendingMutations.delete(objectId);
1103
+ this._pendingMutations.delete(location);
1097
1104
  if (pending !== null) {
1098
- // Already emitted objectUpdated optimistically.
1099
- // Emit again only if data changed (AI resolved placeholders).
1100
1105
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1101
- this.emit('objectUpdated', { objectId, object, source });
1106
+ this.emit('objectUpdated', { location, object, source });
1102
1107
  }
1103
1108
  }
1104
1109
  }
1105
1110
  else {
1106
- // Remote event
1107
- this.emit('objectUpdated', { objectId, object, source });
1111
+ this.emit('objectUpdated', { location, object, source });
1108
1112
  }
1109
1113
  }
1110
- /**
1111
- * Handle an object_deleted SSE event.
1112
- * Deduplicates against optimistic local deletes.
1113
- * @internal
1114
- */
1115
- _handleObjectDeleted(objectId, source) {
1116
- // Remove from local ID list
1117
- this._objectIds = this._objectIds.filter(id => id !== objectId);
1118
- const pending = this._pendingMutations.get(objectId);
1114
+ /** @internal */
1115
+ _handleObjectDeleted(location, source) {
1116
+ this._objectLocations = this._objectLocations.filter(l => l !== location);
1117
+ const pending = this._pendingMutations.get(location);
1119
1118
  if (pending !== undefined) {
1120
- // This is our own delete echoed back — already emitted
1121
- this._pendingMutations.delete(objectId);
1119
+ this._pendingMutations.delete(location);
1122
1120
  }
1123
1121
  else {
1124
- // Remote event
1125
- this.emit('objectDeleted', { objectId, source });
1122
+ this.emit('objectDeleted', { location, source });
1123
+ }
1124
+ }
1125
+ /** @internal */
1126
+ _handleObjectMoved(from, to, object, source) {
1127
+ this._deliverObject(to, object);
1128
+ // Drop old location, insert new one at the front.
1129
+ this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
1130
+ const pending = this._pendingMutations.get(to);
1131
+ if (pending !== undefined) {
1132
+ this._pendingMutations.delete(to);
1133
+ if (pending !== null) {
1134
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
1135
+ this.emit('objectUpdated', { location: to, object, source });
1136
+ }
1137
+ }
1138
+ }
1139
+ else {
1140
+ this.emit('objectMoved', { from, to, object, source });
1126
1141
  }
1127
1142
  }
1128
1143
  }
1129
1144
  /**
1130
1145
  * A lightweight handle for a specific conversation within a channel.
1131
- *
1132
- * Scopes AI and mutation operations to a particular conversation's interaction
1133
- * history, while sharing the channel's single SSE connection and object state.
1134
- *
1135
- * Obtain via `channel.conversation('thread-id')`.
1136
- * Conversations are auto-created on first interaction.
1137
1146
  */
1138
1147
  export class ConversationHandle {
1139
1148
  /** @internal */
@@ -1179,16 +1188,20 @@ export class ConversationHandle {
1179
1188
  return this._channel._findObjectsImpl(options, this._conversationId);
1180
1189
  }
1181
1190
  /** Create a new object. */
1182
- async createObject(options) {
1183
- return this._channel._createObjectImpl(options, this._conversationId);
1191
+ async createObject(collection, body, options) {
1192
+ return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1184
1193
  }
1185
1194
  /** Update an existing object. */
1186
- async updateObject(objectId, options) {
1187
- return this._channel._updateObjectImpl(objectId, options, this._conversationId);
1195
+ async updateObject(location, options) {
1196
+ return this._channel._updateObjectImpl(location, options, this._conversationId);
1197
+ }
1198
+ /** Move (rename/relocate) an object. */
1199
+ async moveObject(from, to, options) {
1200
+ return this._channel._moveObjectImpl(from, to, options, this._conversationId);
1188
1201
  }
1189
- /** Delete objects by IDs. */
1190
- async deleteObjects(objectIds) {
1191
- return this._channel._deleteObjectsImpl(objectIds, this._conversationId);
1202
+ /** Delete objects by location. */
1203
+ async deleteObjects(locations) {
1204
+ return this._channel._deleteObjectsImpl(locations, this._conversationId);
1192
1205
  }
1193
1206
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1194
1207
  async prompt(text, options) {