@rool-dev/sdk 0.9.0-dev.f75b2d1 → 0.10.0-dev.97d979e

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