@rool-dev/sdk 0.9.0-dev.bcd88e4 → 0.9.0-dev.c1da33d

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,138 +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
- // Use data.id if provided (string), otherwise generate
429
- const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
430
- // Validate ID format: alphanumeric, hyphens, underscores only
431
- if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
432
- throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
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.');
433
478
  }
434
- const dataWithId = { ...data, id: objectId };
435
- // Emit optimistic event and track for dedup
436
- this._pendingMutations.set(objectId, dataWithId);
437
- this.emit('objectCreated', { objectId, object: dataWithId, 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' });
438
484
  try {
439
- // Await mutation — server processes AI placeholders before responding.
440
- // SSE events arrive during the await and are buffered via _deliverObject.
441
485
  const interactionId = generateEntityId();
442
- const { message } = await this.graphqlClient.createObject(this.id, dataWithId, this._channelId, conversationId, interactionId, ephemeral);
443
- // Collect resolved object from buffer (or wait if not yet arrived)
444
- const object = await this._collectObject(objectId);
445
- 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 };
446
489
  }
447
490
  catch (error) {
448
491
  this.logger.error('[RoolChannel] Failed to create object:', error);
449
- this._pendingMutations.delete(objectId);
450
- this._cancelCollector(objectId);
451
- // Emit reset so UI can recover from the optimistic event
492
+ this._pendingMutations.delete(location);
493
+ this._cancelCollector(location);
452
494
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
453
495
  this.emit('reset', { source: 'system' });
454
496
  throw error;
@@ -456,98 +498,141 @@ export class RoolChannel extends EventEmitter {
456
498
  }
457
499
  /**
458
500
  * Update an existing object.
459
- * @param objectId - The ID of the object to update
460
- * @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.
461
- * @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.
462
505
  * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
463
- * @returns The updated object (with AI-filled content) and message
464
506
  */
465
- async updateObject(objectId, options) {
466
- return this._updateObjectImpl(objectId, options, this._conversationId);
507
+ async updateObject(location, options) {
508
+ return this._updateObjectImpl(location, options, this._conversationId);
467
509
  }
468
510
  /** @internal */
469
- async _updateObjectImpl(objectId, options, conversationId) {
470
- const { data, ephemeral } = options;
471
- // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
472
- if (data?.id !== undefined && data.id !== null) {
473
- throw new Error('Cannot change id in updateObject. 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.');
474
516
  }
475
- if (data && ('id' in data)) {
476
- throw new Error('Cannot delete id field. The id field is immutable after creation.');
477
- }
478
- // Normalize undefined to null (for JSON serialization) and build server data
479
- let serverData;
517
+ // Normalize undefined to null (for JSON serialization) and build server patch
518
+ let serverPatch;
480
519
  if (data) {
481
- serverData = {};
520
+ serverPatch = {};
482
521
  for (const [key, value] of Object.entries(data)) {
483
- // Convert undefined to null for wire protocol
484
- serverData[key] = value === undefined ? null : value;
522
+ serverPatch[key] = value === undefined ? null : value;
485
523
  }
486
524
  }
487
525
  // Emit optimistic event if we have data changes
488
526
  if (data) {
489
- // Build optimistic object (best effort — we may not have the current state)
490
- const optimistic = { id: objectId, ...data };
491
- this._pendingMutations.set(objectId, optimistic);
492
- 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' });
493
531
  }
494
532
  try {
495
533
  const interactionId = generateEntityId();
496
- const { message } = await this.graphqlClient.updateObject(this.id, objectId, this._channelId, conversationId, interactionId, serverData, options.prompt, ephemeral);
497
- const object = await this._collectObject(objectId);
498
- 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 };
499
542
  }
500
543
  catch (error) {
501
544
  this.logger.error('[RoolChannel] Failed to update object:', error);
502
- this._pendingMutations.delete(objectId);
503
- 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);
504
595
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
505
596
  this.emit('reset', { source: 'system' });
506
597
  throw error;
507
598
  }
508
599
  }
509
600
  /**
510
- * Delete objects by IDs.
511
- * 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.
512
603
  */
513
- async deleteObjects(objectIds) {
514
- return this._deleteObjectsImpl(objectIds, this._conversationId);
604
+ async deleteObjects(locations) {
605
+ return this._deleteObjectsImpl(locations, this._conversationId);
515
606
  }
516
607
  /** @internal */
517
- async _deleteObjectsImpl(objectIds, conversationId) {
518
- if (objectIds.length === 0)
608
+ async _deleteObjectsImpl(locations, conversationId) {
609
+ if (locations.length === 0)
519
610
  return;
611
+ const canonical = locations.map(normalizeLocation);
520
612
  // Track for dedup and emit optimistic events
521
- for (const objectId of objectIds) {
522
- this._pendingMutations.set(objectId, null);
523
- 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' });
524
616
  }
525
617
  try {
526
- 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);
527
620
  }
528
621
  catch (error) {
529
622
  this.logger.error('[RoolChannel] Failed to delete objects:', error);
530
- for (const objectId of objectIds) {
531
- this._pendingMutations.delete(objectId);
623
+ for (const location of canonical) {
624
+ this._pendingMutations.delete(location);
532
625
  }
533
626
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
534
627
  this.emit('reset', { source: 'system' });
535
628
  throw error;
536
629
  }
537
630
  }
538
- /**
539
- * Get the current schema for this space.
540
- * Returns a map of collection names to their definitions.
541
- */
631
+ /** Get the current schema for this space. */
542
632
  getSchema() {
543
633
  return this._schema;
544
634
  }
545
- /**
546
- * Create a new collection schema.
547
- * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
548
- * @param fields - Field definitions for the collection
549
- * @returns The created CollectionDef
550
- */
635
+ /** Create a new collection schema. */
551
636
  async createCollection(name, fields) {
552
637
  return this._createCollectionImpl(name, fields, this._conversationId);
553
638
  }
@@ -568,12 +653,7 @@ export class RoolChannel extends EventEmitter {
568
653
  throw error;
569
654
  }
570
655
  }
571
- /**
572
- * Alter an existing collection schema, replacing its field definitions.
573
- * @param name - Name of the collection to alter
574
- * @param fields - New field definitions (replaces all existing fields)
575
- * @returns The updated CollectionDef
576
- */
656
+ /** Alter an existing collection schema, replacing its field definitions. */
577
657
  async alterCollection(name, fields) {
578
658
  return this._alterCollectionImpl(name, fields, this._conversationId);
579
659
  }
@@ -583,7 +663,6 @@ export class RoolChannel extends EventEmitter {
583
663
  throw new Error(`Collection "${name}" not found`);
584
664
  }
585
665
  const previous = this._schema[name];
586
- // Optimistic local update
587
666
  this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
588
667
  try {
589
668
  return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
@@ -594,10 +673,7 @@ export class RoolChannel extends EventEmitter {
594
673
  throw error;
595
674
  }
596
675
  }
597
- /**
598
- * Drop a collection schema.
599
- * @param name - Name of the collection to drop
600
- */
676
+ /** Drop a collection schema. */
601
677
  async dropCollection(name) {
602
678
  return this._dropCollectionImpl(name, this._conversationId);
603
679
  }
@@ -607,7 +683,6 @@ export class RoolChannel extends EventEmitter {
607
683
  throw new Error(`Collection "${name}" not found`);
608
684
  }
609
685
  const previous = this._schema[name];
610
- // Optimistic local update
611
686
  delete this._schema[name];
612
687
  try {
613
688
  await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
@@ -620,7 +695,6 @@ export class RoolChannel extends EventEmitter {
620
695
  }
621
696
  /**
622
697
  * Get the system instruction for the current conversation.
623
- * Returns undefined if no system instruction is set.
624
698
  */
625
699
  getSystemInstruction() {
626
700
  return this._getSystemInstructionImpl(this._conversationId);
@@ -629,16 +703,12 @@ export class RoolChannel extends EventEmitter {
629
703
  _getSystemInstructionImpl(conversationId) {
630
704
  return this._channel?.conversations[conversationId]?.systemInstruction;
631
705
  }
632
- /**
633
- * Set the system instruction for the current conversation.
634
- * Pass null to clear the instruction.
635
- */
706
+ /** Set the system instruction for the current conversation. */
636
707
  async setSystemInstruction(instruction) {
637
708
  return this._setSystemInstructionImpl(instruction, this._conversationId);
638
709
  }
639
710
  /** @internal */
640
711
  async _setSystemInstructionImpl(instruction, conversationId) {
641
- // Optimistic local update
642
712
  this._ensureConversationImpl(conversationId);
643
713
  const conv = this._channel.conversations[conversationId];
644
714
  const previousInstruction = conv.systemInstruction;
@@ -648,7 +718,6 @@ export class RoolChannel extends EventEmitter {
648
718
  else {
649
719
  conv.systemInstruction = instruction;
650
720
  }
651
- // Emit events for backward compat and new API
652
721
  this.emit('conversationUpdated', {
653
722
  conversationId,
654
723
  channelId: this._channelId,
@@ -660,13 +729,11 @@ export class RoolChannel extends EventEmitter {
660
729
  source: 'local_user',
661
730
  });
662
731
  }
663
- // Call server
664
732
  try {
665
733
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
666
734
  }
667
735
  catch (error) {
668
736
  this.logger.error('[RoolChannel] Failed to set system instruction:', error);
669
- // Rollback
670
737
  if (previousInstruction === undefined) {
671
738
  delete conv.systemInstruction;
672
739
  }
@@ -676,15 +743,12 @@ export class RoolChannel extends EventEmitter {
676
743
  throw error;
677
744
  }
678
745
  }
679
- /**
680
- * Rename the current conversation.
681
- */
746
+ /** Rename the current conversation. */
682
747
  async renameConversation(name) {
683
748
  return this._renameConversationImpl(name, this._conversationId);
684
749
  }
685
750
  /** @internal */
686
751
  async _renameConversationImpl(name, conversationId) {
687
- // Optimistic local update
688
752
  this._ensureConversationImpl(conversationId);
689
753
  const conv = this._channel.conversations[conversationId];
690
754
  const previousName = conv.name;
@@ -700,21 +764,16 @@ export class RoolChannel extends EventEmitter {
700
764
  source: 'local_user',
701
765
  });
702
766
  }
703
- // Call server
704
767
  try {
705
768
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
706
769
  }
707
770
  catch (error) {
708
771
  this.logger.error('[RoolChannel] Failed to rename conversation:', error);
709
- // Rollback
710
772
  conv.name = previousName;
711
773
  throw error;
712
774
  }
713
775
  }
714
- /**
715
- * Ensure a conversation exists in the local channel cache.
716
- * @internal
717
- */
776
+ /** @internal */
718
777
  _ensureConversationImpl(conversationId) {
719
778
  if (!this._channel) {
720
779
  this._channel = {
@@ -731,10 +790,7 @@ export class RoolChannel extends EventEmitter {
731
790
  };
732
791
  }
733
792
  }
734
- /**
735
- * Set a space-level metadata value.
736
- * Metadata is stored in meta and hidden from AI operations.
737
- */
793
+ /** Set a space-level metadata value. */
738
794
  setMetadata(key, value) {
739
795
  this._setMetadataImpl(key, value, this._conversationId);
740
796
  }
@@ -743,50 +799,43 @@ export class RoolChannel extends EventEmitter {
743
799
  this._meta[key] = value;
744
800
  this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
745
801
  // Fire-and-forget server call
746
- this.graphqlClient.setSpaceMeta(this.id, this._meta, this._channelId, conversationId)
802
+ this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
747
803
  .catch((error) => {
748
804
  this.logger.error('[RoolChannel] Failed to set meta:', error);
749
805
  });
750
806
  }
751
- /**
752
- * Get a space-level metadata value.
753
- */
807
+ /** Get a space-level metadata value. */
754
808
  getMetadata(key) {
755
809
  return this._meta[key];
756
810
  }
757
- /**
758
- * Get all space-level metadata.
759
- */
811
+ /** Get all space-level metadata. */
760
812
  getAllMetadata() {
761
813
  return this._meta;
762
814
  }
763
815
  /**
764
816
  * Send a prompt to the AI agent for space manipulation.
765
- * @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.
766
818
  */
767
819
  async prompt(prompt, options) {
768
820
  return this._promptImpl(prompt, options, this._conversationId);
769
821
  }
770
822
  /** @internal */
771
823
  async _promptImpl(prompt, options, conversationId) {
772
- // Upload attachments via media endpoint, then send URLs to the server
773
- const { attachments, parentInteractionId: explicitParent, signal, ...rest } = options ?? {};
774
- let attachmentUrls;
824
+ const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
825
+ const interactionId = generateEntityId();
826
+ let attachmentRefs;
775
827
  if (attachments?.length) {
776
- 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)));
777
829
  }
778
830
  // Auto-continue from active leaf if no explicit parent provided
779
831
  const parentInteractionId = explicitParent !== undefined
780
832
  ? explicitParent
781
833
  : (this._getActiveLeafImpl(conversationId) ?? null);
782
- const interactionId = generateEntityId();
783
834
  // Optimistically set active leaf before the server call.
784
835
  this._activeLeaves.set(conversationId, interactionId);
785
836
  let onAbort;
786
837
  if (signal) {
787
838
  if (signal.aborted) {
788
- // Caller aborted before we even started; fire-and-forget the stop so
789
- // the server-side prompt (about to start) is cancelled too.
790
839
  this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
791
840
  }
792
841
  else {
@@ -798,29 +847,33 @@ export class RoolChannel extends EventEmitter {
798
847
  }
799
848
  let result;
800
849
  try {
801
- 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
+ });
802
857
  }
803
858
  finally {
804
859
  if (onAbort)
805
860
  signal.removeEventListener('abort', onAbort);
806
861
  }
807
862
  // Collect modified objects — they arrive via SSE events during/after the mutation.
808
- // Try collecting from buffer first, then fetch any missing from server.
809
863
  const objects = [];
810
864
  const missing = [];
811
- for (const id of result.modifiedObjectIds) {
812
- const buffered = this._objectBuffer.get(id);
865
+ for (const location of result.modifiedObjectLocations) {
866
+ const buffered = this._objectBuffer.get(location);
813
867
  if (buffered) {
814
- this._objectBuffer.delete(id);
868
+ this._objectBuffer.delete(location);
815
869
  objects.push(buffered);
816
870
  }
817
871
  else {
818
- missing.push(id);
872
+ missing.push(location);
819
873
  }
820
874
  }
821
- // Fetch any objects not yet received via SSE
822
875
  if (missing.length > 0) {
823
- 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)));
824
877
  for (const obj of fetched) {
825
878
  if (obj)
826
879
  objects.push(obj);
@@ -831,119 +884,89 @@ export class RoolChannel extends EventEmitter {
831
884
  objects,
832
885
  };
833
886
  }
834
- /**
835
- * Rename this channel.
836
- */
887
+ /** Rename this channel. */
837
888
  async rename(newName) {
838
- // Optimistic local update
839
889
  const previousName = this._channel?.name;
840
890
  if (this._channel) {
841
891
  this._channel.name = newName;
842
892
  }
843
893
  this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
844
- // Call server
845
894
  try {
846
895
  await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
847
896
  }
848
897
  catch (error) {
849
898
  this.logger.error('[RoolChannel] Failed to rename channel:', error);
850
- // Rollback
851
899
  if (this._channel) {
852
900
  this._channel.name = previousName;
853
901
  }
854
902
  throw error;
855
903
  }
856
904
  }
857
- /**
858
- * List all media files for this space.
859
- */
860
- async listMedia() {
861
- return this.mediaClient.list(this._id);
862
- }
863
- /**
864
- * Upload a file to this space. Returns the URL.
865
- */
866
- async uploadMedia(file) {
867
- return this.mediaClient.upload(this._id, file);
868
- }
869
- /**
870
- * Fetch any URL, returning headers and a blob() method (like fetch Response).
871
- * Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
872
- * Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
873
- */
874
- async fetchMedia(url, options) {
875
- return this.mediaClient.fetch(this._id, url, options);
876
- }
877
- /**
878
- * Delete a media file by URL.
879
- */
880
- async deleteMedia(url) {
881
- return this.mediaClient.delete(this._id, url);
882
- }
883
905
  /**
884
906
  * Fetch an external URL via the server proxy, bypassing CORS restrictions.
885
- * Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
886
- *
887
- * @param url - The URL to fetch
888
- * @param init - Optional method, headers, and body
889
- * @returns The proxied Response
890
907
  */
891
908
  async fetch(url, init) {
892
- 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()}`);
893
925
  }
894
926
  /**
895
927
  * Register a collector that resolves when the object arrives via SSE.
896
- * If the object is already in the buffer (arrived before collector), resolves immediately.
897
928
  * @internal
898
929
  */
899
- _collectObject(objectId) {
930
+ _collectObject(location) {
900
931
  return new Promise((resolve, reject) => {
901
- // Check buffer first — SSE event may have arrived before the HTTP response
902
- const buffered = this._objectBuffer.get(objectId);
932
+ const buffered = this._objectBuffer.get(location);
903
933
  if (buffered) {
904
- this._objectBuffer.delete(objectId);
934
+ this._objectBuffer.delete(location);
905
935
  resolve(buffered);
906
936
  return;
907
937
  }
908
938
  const timer = setTimeout(() => {
909
- this._objectResolvers.delete(objectId);
939
+ this._objectResolvers.delete(location);
910
940
  // Fallback: try to fetch from server
911
- this.graphqlClient.getObject(this._id, objectId).then(obj => {
941
+ this.graphqlClient.getObject(this._id, location).then(obj => {
912
942
  if (obj) {
913
943
  resolve(obj);
914
944
  }
915
945
  else {
916
- reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
946
+ reject(new Error(`Timeout waiting for object ${location} from SSE`));
917
947
  }
918
948
  }).catch(reject);
919
949
  }, OBJECT_COLLECT_TIMEOUT);
920
- this._objectResolvers.set(objectId, (obj) => {
950
+ this._objectResolvers.set(location, (obj) => {
921
951
  clearTimeout(timer);
922
952
  resolve(obj);
923
953
  });
924
954
  });
925
955
  }
926
- /**
927
- * Cancel a pending object collector (e.g., on mutation error).
928
- * @internal
929
- */
930
- _cancelCollector(objectId) {
931
- this._objectResolvers.delete(objectId);
932
- this._objectBuffer.delete(objectId);
956
+ /** @internal */
957
+ _cancelCollector(location) {
958
+ this._objectResolvers.delete(location);
959
+ this._objectBuffer.delete(location);
933
960
  }
934
- /**
935
- * Deliver an object to a pending collector, or buffer it for later collection.
936
- * @internal
937
- */
938
- _deliverObject(objectId, object) {
939
- const resolver = this._objectResolvers.get(objectId);
961
+ /** @internal */
962
+ _deliverObject(location, object) {
963
+ const resolver = this._objectResolvers.get(location);
940
964
  if (resolver) {
941
965
  resolver(object);
942
- this._objectResolvers.delete(objectId);
966
+ this._objectResolvers.delete(location);
943
967
  }
944
968
  else {
945
- // Buffer for prompt() or late collectors
946
- this._objectBuffer.set(objectId, object);
969
+ this._objectBuffer.set(location, object);
947
970
  }
948
971
  }
949
972
  /**
@@ -951,7 +974,6 @@ export class RoolChannel extends EventEmitter {
951
974
  * @internal
952
975
  */
953
976
  handleChannelEvent(event) {
954
- // Ignore events after close - the channel is being torn down
955
977
  if (this._closed)
956
978
  return;
957
979
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
@@ -960,23 +982,31 @@ export class RoolChannel extends EventEmitter {
960
982
  // Resync is handled by the client via _applyResyncData.
961
983
  break;
962
984
  case 'object_created':
963
- if (event.objectId && event.object) {
985
+ if (event.location && event.object) {
964
986
  if (event.objectStat)
965
- this._objectStats.set(event.objectId, event.objectStat);
966
- this._handleObjectCreated(event.objectId, event.object, changeSource);
987
+ this._objectStats.set(event.location, event.objectStat);
988
+ this._handleObjectCreated(event.location, event.object, changeSource);
967
989
  }
968
990
  break;
969
991
  case 'object_updated':
970
- if (event.objectId && event.object) {
992
+ if (event.location && event.object) {
971
993
  if (event.objectStat)
972
- this._objectStats.set(event.objectId, event.objectStat);
973
- this._handleObjectUpdated(event.objectId, event.object, changeSource);
994
+ this._objectStats.set(event.location, event.objectStat);
995
+ this._handleObjectUpdated(event.location, event.object, changeSource);
974
996
  }
975
997
  break;
976
998
  case 'object_deleted':
977
- if (event.objectId) {
978
- this._objectStats.delete(event.objectId);
979
- 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);
980
1010
  }
981
1011
  break;
982
1012
  case 'schema_updated':
@@ -992,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
992
1022
  }
993
1023
  break;
994
1024
  case 'channel_updated':
995
- // Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
996
1025
  if (event.channelId === this._channelId && event.channel) {
997
1026
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
998
1027
  this._channel = event.channel;
@@ -1002,7 +1031,6 @@ export class RoolChannel extends EventEmitter {
1002
1031
  }
1003
1032
  break;
1004
1033
  case 'conversation_updated':
1005
- // Only update if it's our channel
1006
1034
  if (event.channelId === this._channelId && event.conversationId) {
1007
1035
  if (!this._channel) {
1008
1036
  this._channel = {
@@ -1013,17 +1041,13 @@ export class RoolChannel extends EventEmitter {
1013
1041
  }
1014
1042
  const prev = this._channel.conversations[event.conversationId];
1015
1043
  if (event.conversation) {
1016
- // Update or create conversation in local cache
1017
1044
  this._channel.conversations[event.conversationId] = event.conversation;
1018
1045
  }
1019
1046
  else {
1020
- // Conversation was deleted
1021
1047
  delete this._channel.conversations[event.conversationId];
1022
1048
  }
1023
- // Skip emit if data is unchanged (e.g. echo of our own optimistic update)
1024
1049
  if (JSON.stringify(prev) === JSON.stringify(event.conversation))
1025
1050
  break;
1026
- // Auto-advance active leaf if someone continued our current branch
1027
1051
  if (event.conversation && !Array.isArray(event.conversation.interactions)) {
1028
1052
  const currentLeaf = this._getActiveLeafImpl(event.conversationId);
1029
1053
  if (currentLeaf) {
@@ -1035,13 +1059,11 @@ export class RoolChannel extends EventEmitter {
1035
1059
  }
1036
1060
  }
1037
1061
  }
1038
- // Emit the new conversationUpdated event
1039
1062
  this.emit('conversationUpdated', {
1040
1063
  conversationId: event.conversationId,
1041
1064
  channelId: event.channelId,
1042
1065
  source: changeSource,
1043
1066
  });
1044
- // Backward compat: also emit channelUpdated when the active conversation updates
1045
1067
  if (event.conversationId === this._conversationId) {
1046
1068
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1047
1069
  }
@@ -1052,87 +1074,75 @@ export class RoolChannel extends EventEmitter {
1052
1074
  break;
1053
1075
  }
1054
1076
  }
1055
- /**
1056
- * Handle an object_created SSE event.
1057
- * Deduplicates against optimistic local creates.
1058
- * @internal
1059
- */
1060
- _handleObjectCreated(objectId, object, source) {
1061
- // Deliver to any pending collector (for mutation return values)
1062
- this._deliverObject(objectId, object);
1063
- // Maintain local ID list — prepend (most recently modified first)
1064
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1065
- 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);
1066
1083
  if (pending !== undefined) {
1067
- // This is our own mutation echoed back
1068
- this._pendingMutations.delete(objectId);
1084
+ this._pendingMutations.delete(location);
1069
1085
  if (pending !== null) {
1070
- // It was a create — already emitted objectCreated optimistically.
1086
+ // Already emitted objectCreated optimistically.
1071
1087
  // Emit objectUpdated only if AI resolved placeholders (data changed).
1072
1088
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1073
- this.emit('objectUpdated', { objectId, object, source });
1089
+ this.emit('objectUpdated', { location, object, source });
1074
1090
  }
1075
1091
  }
1076
1092
  }
1077
1093
  else {
1078
- // Remote event emit normally
1079
- this.emit('objectCreated', { objectId, object, source });
1094
+ this.emit('objectCreated', { location, object, source });
1080
1095
  }
1081
1096
  }
1082
- /**
1083
- * Handle an object_updated SSE event.
1084
- * Deduplicates against optimistic local updates.
1085
- * @internal
1086
- */
1087
- _handleObjectUpdated(objectId, object, source) {
1088
- // Deliver to any pending collector
1089
- this._deliverObject(objectId, object);
1090
- // Maintain local ID list — move to front (most recently modified)
1091
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1092
- 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);
1093
1102
  if (pending !== undefined) {
1094
- // This is our own mutation echoed back
1095
- this._pendingMutations.delete(objectId);
1103
+ this._pendingMutations.delete(location);
1096
1104
  if (pending !== null) {
1097
- // Already emitted objectUpdated optimistically.
1098
- // Emit again only if data changed (AI resolved placeholders).
1099
1105
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1100
- this.emit('objectUpdated', { objectId, object, source });
1106
+ this.emit('objectUpdated', { location, object, source });
1101
1107
  }
1102
1108
  }
1103
1109
  }
1104
1110
  else {
1105
- // Remote event
1106
- this.emit('objectUpdated', { objectId, object, source });
1111
+ this.emit('objectUpdated', { location, object, source });
1107
1112
  }
1108
1113
  }
1109
- /**
1110
- * Handle an object_deleted SSE event.
1111
- * Deduplicates against optimistic local deletes.
1112
- * @internal
1113
- */
1114
- _handleObjectDeleted(objectId, source) {
1115
- // Remove from local ID list
1116
- this._objectIds = this._objectIds.filter(id => id !== objectId);
1117
- 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);
1118
+ if (pending !== undefined) {
1119
+ this._pendingMutations.delete(location);
1120
+ }
1121
+ else {
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);
1118
1131
  if (pending !== undefined) {
1119
- // This is our own delete echoed back — already emitted
1120
- this._pendingMutations.delete(objectId);
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
+ }
1121
1138
  }
1122
1139
  else {
1123
- // Remote event
1124
- this.emit('objectDeleted', { objectId, source });
1140
+ this.emit('objectMoved', { from, to, object, source });
1125
1141
  }
1126
1142
  }
1127
1143
  }
1128
1144
  /**
1129
1145
  * A lightweight handle for a specific conversation within a channel.
1130
- *
1131
- * Scopes AI and mutation operations to a particular conversation's interaction
1132
- * history, while sharing the channel's single SSE connection and object state.
1133
- *
1134
- * Obtain via `channel.conversation('thread-id')`.
1135
- * Conversations are auto-created on first interaction.
1136
1146
  */
1137
1147
  export class ConversationHandle {
1138
1148
  /** @internal */
@@ -1178,16 +1188,20 @@ export class ConversationHandle {
1178
1188
  return this._channel._findObjectsImpl(options, this._conversationId);
1179
1189
  }
1180
1190
  /** Create a new object. */
1181
- async createObject(options) {
1182
- return this._channel._createObjectImpl(options, this._conversationId);
1191
+ async createObject(collection, body, options) {
1192
+ return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1183
1193
  }
1184
1194
  /** Update an existing object. */
1185
- async updateObject(objectId, options) {
1186
- 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);
1187
1201
  }
1188
- /** Delete objects by IDs. */
1189
- async deleteObjects(objectIds) {
1190
- return this._channel._deleteObjectsImpl(objectIds, this._conversationId);
1202
+ /** Delete objects by location. */
1203
+ async deleteObjects(locations) {
1204
+ return this._channel._deleteObjectsImpl(locations, this._conversationId);
1191
1205
  }
1192
1206
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1193
1207
  async prompt(text, options) {