@rool-dev/sdk 0.9.0 → 0.10.0-dev.2e3d7c6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +359 -166
  2. package/dist/channel.d.ts +94 -184
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +360 -351
  5. package/dist/channel.js.map +1 -1
  6. package/dist/client.d.ts +32 -5
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +59 -57
  9. package/dist/client.js.map +1 -1
  10. package/dist/graphql.d.ts +35 -19
  11. package/dist/graphql.d.ts.map +1 -1
  12. package/dist/graphql.js +114 -128
  13. package/dist/graphql.js.map +1 -1
  14. package/dist/index.d.ts +7 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/locations.d.ts +34 -0
  19. package/dist/locations.d.ts.map +1 -0
  20. package/dist/locations.js +90 -0
  21. package/dist/locations.js.map +1 -0
  22. package/dist/machine.d.ts +16 -0
  23. package/dist/machine.d.ts.map +1 -0
  24. package/dist/machine.js +51 -0
  25. package/dist/machine.js.map +1 -0
  26. package/dist/rest.d.ts +18 -0
  27. package/dist/rest.d.ts.map +1 -0
  28. package/dist/rest.js +46 -0
  29. package/dist/rest.js.map +1 -0
  30. package/dist/space.d.ts +20 -4
  31. package/dist/space.d.ts.map +1 -1
  32. package/dist/space.js +31 -45
  33. package/dist/space.js.map +1 -1
  34. package/dist/subscription.d.ts.map +1 -1
  35. package/dist/subscription.js +11 -12
  36. package/dist/subscription.js.map +1 -1
  37. package/dist/types.d.ts +79 -59
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/webdav.d.ts +153 -0
  40. package/dist/webdav.d.ts.map +1 -0
  41. package/dist/webdav.js +458 -0
  42. package/dist/webdav.js.map +1 -0
  43. package/package.json +1 -1
  44. package/dist/media.d.ts +0 -70
  45. package/dist/media.d.ts.map +0 -1
  46. package/dist/media.js +0 -228
  47. package/dist/media.js.map +0 -1
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,6 +42,79 @@ function findDefaultLeaf(interactions) {
40
42
  }
41
43
  return best?.id;
42
44
  }
45
+ function attachmentBody(file) {
46
+ if (isFile(file)) {
47
+ return {
48
+ filename: safeAttachmentFilename(file.name, file.type),
49
+ contentType: file.type || 'application/octet-stream',
50
+ body: file,
51
+ };
52
+ }
53
+ if (isBlob(file)) {
54
+ const contentType = file.type || 'application/octet-stream';
55
+ return {
56
+ filename: safeAttachmentFilename('attachment', contentType),
57
+ contentType,
58
+ body: file,
59
+ };
60
+ }
61
+ return {
62
+ filename: safeAttachmentFilename('attachment', file.contentType),
63
+ contentType: file.contentType,
64
+ body: base64Body(file.data),
65
+ };
66
+ }
67
+ function isFile(value) {
68
+ return typeof File !== 'undefined' && value instanceof File;
69
+ }
70
+ function isBlob(value) {
71
+ return typeof Blob !== 'undefined' && value instanceof Blob;
72
+ }
73
+ function safeAttachmentFilename(name, contentType) {
74
+ const fallback = `attachment.${extensionForContentType(contentType)}`;
75
+ const leaf = name.split(/[/\\]/).pop() || fallback;
76
+ const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
77
+ return cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
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,109 @@ 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.
466
+ * @param options.basename - Specific basename to use. If omitted, the SDK generates a random one.
419
467
  * @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
468
+ * @returns The created object and a status message.
421
469
  */
422
- async createObject(options) {
423
- return this._createObjectImpl(options, this._conversationId);
470
+ async createObject(collection, body, options) {
471
+ return this._createObjectImpl(collection, body, options, this._conversationId);
424
472
  }
425
473
  /** @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.`);
433
- }
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' });
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' });
438
480
  try {
439
- // Await mutation — server processes AI placeholders before responding.
440
- // SSE events arrive during the await and are buffered via _deliverObject.
441
481
  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 };
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 };
446
485
  }
447
486
  catch (error) {
448
487
  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
488
+ this._pendingMutations.delete(location);
489
+ this._cancelCollector(location);
452
490
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
453
491
  this.emit('reset', { source: 'system' });
454
492
  throw error;
@@ -456,98 +494,135 @@ export class RoolChannel extends EventEmitter {
456
494
  }
457
495
  /**
458
496
  * 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).
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.
462
501
  * @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
502
  */
465
- async updateObject(objectId, options) {
466
- return this._updateObjectImpl(objectId, options, this._conversationId);
503
+ async updateObject(location, options) {
504
+ return this._updateObjectImpl(location, options, this._conversationId);
467
505
  }
468
506
  /** @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.');
474
- }
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;
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;
480
512
  if (data) {
481
- serverData = {};
513
+ serverPatch = {};
482
514
  for (const [key, value] of Object.entries(data)) {
483
- // Convert undefined to null for wire protocol
484
- serverData[key] = value === undefined ? null : value;
515
+ serverPatch[key] = value === undefined ? null : value;
485
516
  }
486
517
  }
487
518
  // Emit optimistic event if we have data changes
488
519
  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' });
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' });
493
524
  }
494
525
  try {
495
526
  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 };
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 };
499
535
  }
500
536
  catch (error) {
501
537
  this.logger.error('[RoolChannel] Failed to update object:', error);
502
- this._pendingMutations.delete(objectId);
503
- this._cancelCollector(objectId);
538
+ this._pendingMutations.delete(canonical);
539
+ this._cancelCollector(canonical);
504
540
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
505
541
  this.emit('reset', { source: 'system' });
506
542
  throw error;
507
543
  }
508
544
  }
509
545
  /**
510
- * Delete objects by IDs.
511
- * 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.
553
+ */
554
+ async moveObject(from, to, options) {
555
+ return this._moveObjectImpl(from, to, options, this._conversationId);
556
+ }
557
+ /** @internal */
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.
512
593
  */
513
- async deleteObjects(objectIds) {
514
- return this._deleteObjectsImpl(objectIds, this._conversationId);
594
+ async deleteObjects(locations) {
595
+ return this._deleteObjectsImpl(locations, this._conversationId);
515
596
  }
516
597
  /** @internal */
517
- async _deleteObjectsImpl(objectIds, conversationId) {
518
- if (objectIds.length === 0)
598
+ async _deleteObjectsImpl(locations, conversationId) {
599
+ if (locations.length === 0)
519
600
  return;
601
+ const canonical = locations.map(normalizeLocation);
520
602
  // 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' });
603
+ for (const location of canonical) {
604
+ this._pendingMutations.set(location, null);
605
+ this.emit('objectDeleted', { location, source: 'local_user' });
524
606
  }
525
607
  try {
526
- 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);
527
610
  }
528
611
  catch (error) {
529
612
  this.logger.error('[RoolChannel] Failed to delete objects:', error);
530
- for (const objectId of objectIds) {
531
- this._pendingMutations.delete(objectId);
613
+ for (const location of canonical) {
614
+ this._pendingMutations.delete(location);
532
615
  }
533
616
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
534
617
  this.emit('reset', { source: 'system' });
535
618
  throw error;
536
619
  }
537
620
  }
538
- /**
539
- * Get the current schema for this space.
540
- * Returns a map of collection names to their definitions.
541
- */
621
+ /** Get the current schema for this space. */
542
622
  getSchema() {
543
623
  return this._schema;
544
624
  }
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
- */
625
+ /** Create a new collection schema. */
551
626
  async createCollection(name, fields) {
552
627
  return this._createCollectionImpl(name, fields, this._conversationId);
553
628
  }
@@ -568,12 +643,7 @@ export class RoolChannel extends EventEmitter {
568
643
  throw error;
569
644
  }
570
645
  }
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
- */
646
+ /** Alter an existing collection schema, replacing its field definitions. */
577
647
  async alterCollection(name, fields) {
578
648
  return this._alterCollectionImpl(name, fields, this._conversationId);
579
649
  }
@@ -583,7 +653,6 @@ export class RoolChannel extends EventEmitter {
583
653
  throw new Error(`Collection "${name}" not found`);
584
654
  }
585
655
  const previous = this._schema[name];
586
- // Optimistic local update
587
656
  this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
588
657
  try {
589
658
  return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
@@ -594,10 +663,7 @@ export class RoolChannel extends EventEmitter {
594
663
  throw error;
595
664
  }
596
665
  }
597
- /**
598
- * Drop a collection schema.
599
- * @param name - Name of the collection to drop
600
- */
666
+ /** Drop a collection schema. */
601
667
  async dropCollection(name) {
602
668
  return this._dropCollectionImpl(name, this._conversationId);
603
669
  }
@@ -607,7 +673,6 @@ export class RoolChannel extends EventEmitter {
607
673
  throw new Error(`Collection "${name}" not found`);
608
674
  }
609
675
  const previous = this._schema[name];
610
- // Optimistic local update
611
676
  delete this._schema[name];
612
677
  try {
613
678
  await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
@@ -620,7 +685,6 @@ export class RoolChannel extends EventEmitter {
620
685
  }
621
686
  /**
622
687
  * Get the system instruction for the current conversation.
623
- * Returns undefined if no system instruction is set.
624
688
  */
625
689
  getSystemInstruction() {
626
690
  return this._getSystemInstructionImpl(this._conversationId);
@@ -629,16 +693,12 @@ export class RoolChannel extends EventEmitter {
629
693
  _getSystemInstructionImpl(conversationId) {
630
694
  return this._channel?.conversations[conversationId]?.systemInstruction;
631
695
  }
632
- /**
633
- * Set the system instruction for the current conversation.
634
- * Pass null to clear the instruction.
635
- */
696
+ /** Set the system instruction for the current conversation. */
636
697
  async setSystemInstruction(instruction) {
637
698
  return this._setSystemInstructionImpl(instruction, this._conversationId);
638
699
  }
639
700
  /** @internal */
640
701
  async _setSystemInstructionImpl(instruction, conversationId) {
641
- // Optimistic local update
642
702
  this._ensureConversationImpl(conversationId);
643
703
  const conv = this._channel.conversations[conversationId];
644
704
  const previousInstruction = conv.systemInstruction;
@@ -648,7 +708,6 @@ export class RoolChannel extends EventEmitter {
648
708
  else {
649
709
  conv.systemInstruction = instruction;
650
710
  }
651
- // Emit events for backward compat and new API
652
711
  this.emit('conversationUpdated', {
653
712
  conversationId,
654
713
  channelId: this._channelId,
@@ -660,13 +719,11 @@ export class RoolChannel extends EventEmitter {
660
719
  source: 'local_user',
661
720
  });
662
721
  }
663
- // Call server
664
722
  try {
665
723
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
666
724
  }
667
725
  catch (error) {
668
726
  this.logger.error('[RoolChannel] Failed to set system instruction:', error);
669
- // Rollback
670
727
  if (previousInstruction === undefined) {
671
728
  delete conv.systemInstruction;
672
729
  }
@@ -676,15 +733,12 @@ export class RoolChannel extends EventEmitter {
676
733
  throw error;
677
734
  }
678
735
  }
679
- /**
680
- * Rename the current conversation.
681
- */
736
+ /** Rename the current conversation. */
682
737
  async renameConversation(name) {
683
738
  return this._renameConversationImpl(name, this._conversationId);
684
739
  }
685
740
  /** @internal */
686
741
  async _renameConversationImpl(name, conversationId) {
687
- // Optimistic local update
688
742
  this._ensureConversationImpl(conversationId);
689
743
  const conv = this._channel.conversations[conversationId];
690
744
  const previousName = conv.name;
@@ -700,21 +754,16 @@ export class RoolChannel extends EventEmitter {
700
754
  source: 'local_user',
701
755
  });
702
756
  }
703
- // Call server
704
757
  try {
705
758
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
706
759
  }
707
760
  catch (error) {
708
761
  this.logger.error('[RoolChannel] Failed to rename conversation:', error);
709
- // Rollback
710
762
  conv.name = previousName;
711
763
  throw error;
712
764
  }
713
765
  }
714
- /**
715
- * Ensure a conversation exists in the local channel cache.
716
- * @internal
717
- */
766
+ /** @internal */
718
767
  _ensureConversationImpl(conversationId) {
719
768
  if (!this._channel) {
720
769
  this._channel = {
@@ -731,10 +780,7 @@ export class RoolChannel extends EventEmitter {
731
780
  };
732
781
  }
733
782
  }
734
- /**
735
- * Set a space-level metadata value.
736
- * Metadata is stored in meta and hidden from AI operations.
737
- */
783
+ /** Set a space-level metadata value. */
738
784
  setMetadata(key, value) {
739
785
  this._setMetadataImpl(key, value, this._conversationId);
740
786
  }
@@ -743,50 +789,44 @@ export class RoolChannel extends EventEmitter {
743
789
  this._meta[key] = value;
744
790
  this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
745
791
  // Fire-and-forget server call
746
- this.graphqlClient.setSpaceMeta(this.id, this._meta, this._channelId, conversationId)
792
+ this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
747
793
  .catch((error) => {
748
794
  this.logger.error('[RoolChannel] Failed to set meta:', error);
749
795
  });
750
796
  }
751
- /**
752
- * Get a space-level metadata value.
753
- */
797
+ /** Get a space-level metadata value. */
754
798
  getMetadata(key) {
755
799
  return this._meta[key];
756
800
  }
757
- /**
758
- * Get all space-level metadata.
759
- */
801
+ /** Get all space-level metadata. */
760
802
  getAllMetadata() {
761
803
  return this._meta;
762
804
  }
763
805
  /**
764
806
  * 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
807
+ * @returns The message from the AI and the list of objects that were created or modified.
766
808
  */
767
809
  async prompt(prompt, options) {
768
810
  return this._promptImpl(prompt, options, this._conversationId);
769
811
  }
770
812
  /** @internal */
771
813
  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;
814
+ const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
815
+ const interactionId = generateEntityId();
816
+ let attachmentRefs;
775
817
  if (attachments?.length) {
776
- attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
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('/')}`);
777
820
  }
778
821
  // Auto-continue from active leaf if no explicit parent provided
779
822
  const parentInteractionId = explicitParent !== undefined
780
823
  ? explicitParent
781
824
  : (this._getActiveLeafImpl(conversationId) ?? null);
782
- const interactionId = generateEntityId();
783
825
  // Optimistically set active leaf before the server call.
784
826
  this._activeLeaves.set(conversationId, interactionId);
785
827
  let onAbort;
786
828
  if (signal) {
787
829
  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
830
  this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
791
831
  }
792
832
  else {
@@ -798,29 +838,33 @@ export class RoolChannel extends EventEmitter {
798
838
  }
799
839
  let result;
800
840
  try {
801
- 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
+ });
802
848
  }
803
849
  finally {
804
850
  if (onAbort)
805
851
  signal.removeEventListener('abort', onAbort);
806
852
  }
807
853
  // 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
854
  const objects = [];
810
855
  const missing = [];
811
- for (const id of result.modifiedObjectIds) {
812
- const buffered = this._objectBuffer.get(id);
856
+ for (const location of result.modifiedObjectLocations) {
857
+ const buffered = this._objectBuffer.get(location);
813
858
  if (buffered) {
814
- this._objectBuffer.delete(id);
859
+ this._objectBuffer.delete(location);
815
860
  objects.push(buffered);
816
861
  }
817
862
  else {
818
- missing.push(id);
863
+ missing.push(location);
819
864
  }
820
865
  }
821
- // Fetch any objects not yet received via SSE
822
866
  if (missing.length > 0) {
823
- 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)));
824
868
  for (const obj of fetched) {
825
869
  if (obj)
826
870
  objects.push(obj);
@@ -831,119 +875,93 @@ export class RoolChannel extends EventEmitter {
831
875
  objects,
832
876
  };
833
877
  }
834
- /**
835
- * Rename this channel.
836
- */
878
+ /** Rename this channel. */
837
879
  async rename(newName) {
838
- // Optimistic local update
839
880
  const previousName = this._channel?.name;
840
881
  if (this._channel) {
841
882
  this._channel.name = newName;
842
883
  }
843
884
  this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
844
- // Call server
845
885
  try {
846
886
  await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
847
887
  }
848
888
  catch (error) {
849
889
  this.logger.error('[RoolChannel] Failed to rename channel:', error);
850
- // Rollback
851
890
  if (this._channel) {
852
891
  this._channel.name = previousName;
853
892
  }
854
893
  throw error;
855
894
  }
856
895
  }
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
896
  /**
884
897
  * 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
898
  */
891
899
  async fetch(url, init) {
892
- return this.mediaClient.proxyFetch(this._id, url, init);
900
+ return this.restClient.proxyFetch(this._id, url, init);
901
+ }
902
+ async uploadAttachment(file, conversationId) {
903
+ await this.ensureCollection('attachments');
904
+ const directory = `attachments/${conversationId}`;
905
+ await this.ensureCollection(directory);
906
+ const attachment = attachmentBody(file);
907
+ const path = `${directory}/${attachment.filename}`;
908
+ await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
909
+ const resource = resolveMachineResource(`/rool-drive/${path}`);
910
+ if (!resource)
911
+ throw new Error('Failed to resolve uploaded attachment');
912
+ return resource;
913
+ }
914
+ async ensureCollection(path) {
915
+ // Note: not an object collection, a folder, which is "collection" in webdav land
916
+ const response = await this.webdav.request('MKCOL', path, { collection: true });
917
+ if (response.status === 201 || response.status === 405)
918
+ return;
919
+ throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
893
920
  }
894
921
  /**
895
922
  * 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
923
  * @internal
898
924
  */
899
- _collectObject(objectId) {
925
+ _collectObject(location) {
900
926
  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);
927
+ const buffered = this._objectBuffer.get(location);
903
928
  if (buffered) {
904
- this._objectBuffer.delete(objectId);
929
+ this._objectBuffer.delete(location);
905
930
  resolve(buffered);
906
931
  return;
907
932
  }
908
933
  const timer = setTimeout(() => {
909
- this._objectResolvers.delete(objectId);
934
+ this._objectResolvers.delete(location);
910
935
  // Fallback: try to fetch from server
911
- this.graphqlClient.getObject(this._id, objectId).then(obj => {
936
+ this.graphqlClient.getObject(this._id, location).then(obj => {
912
937
  if (obj) {
913
938
  resolve(obj);
914
939
  }
915
940
  else {
916
- reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
941
+ reject(new Error(`Timeout waiting for object ${location} from SSE`));
917
942
  }
918
943
  }).catch(reject);
919
944
  }, OBJECT_COLLECT_TIMEOUT);
920
- this._objectResolvers.set(objectId, (obj) => {
945
+ this._objectResolvers.set(location, (obj) => {
921
946
  clearTimeout(timer);
922
947
  resolve(obj);
923
948
  });
924
949
  });
925
950
  }
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);
951
+ /** @internal */
952
+ _cancelCollector(location) {
953
+ this._objectResolvers.delete(location);
954
+ this._objectBuffer.delete(location);
933
955
  }
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);
956
+ /** @internal */
957
+ _deliverObject(location, object) {
958
+ const resolver = this._objectResolvers.get(location);
940
959
  if (resolver) {
941
960
  resolver(object);
942
- this._objectResolvers.delete(objectId);
961
+ this._objectResolvers.delete(location);
943
962
  }
944
963
  else {
945
- // Buffer for prompt() or late collectors
946
- this._objectBuffer.set(objectId, object);
964
+ this._objectBuffer.set(location, object);
947
965
  }
948
966
  }
949
967
  /**
@@ -951,7 +969,6 @@ export class RoolChannel extends EventEmitter {
951
969
  * @internal
952
970
  */
953
971
  handleChannelEvent(event) {
954
- // Ignore events after close - the channel is being torn down
955
972
  if (this._closed)
956
973
  return;
957
974
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
@@ -960,23 +977,31 @@ export class RoolChannel extends EventEmitter {
960
977
  // Resync is handled by the client via _applyResyncData.
961
978
  break;
962
979
  case 'object_created':
963
- if (event.objectId && event.object) {
980
+ if (event.location && event.object) {
964
981
  if (event.objectStat)
965
- this._objectStats.set(event.objectId, event.objectStat);
966
- this._handleObjectCreated(event.objectId, event.object, changeSource);
982
+ this._objectStats.set(event.location, event.objectStat);
983
+ this._handleObjectCreated(event.location, event.object, changeSource);
967
984
  }
968
985
  break;
969
986
  case 'object_updated':
970
- if (event.objectId && event.object) {
987
+ if (event.location && event.object) {
971
988
  if (event.objectStat)
972
- this._objectStats.set(event.objectId, event.objectStat);
973
- this._handleObjectUpdated(event.objectId, event.object, changeSource);
989
+ this._objectStats.set(event.location, event.objectStat);
990
+ this._handleObjectUpdated(event.location, event.object, changeSource);
974
991
  }
975
992
  break;
976
993
  case 'object_deleted':
977
- if (event.objectId) {
978
- this._objectStats.delete(event.objectId);
979
- 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);
980
1005
  }
981
1006
  break;
982
1007
  case 'schema_updated':
@@ -992,7 +1017,6 @@ export class RoolChannel extends EventEmitter {
992
1017
  }
993
1018
  break;
994
1019
  case 'channel_updated':
995
- // Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
996
1020
  if (event.channelId === this._channelId && event.channel) {
997
1021
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
998
1022
  this._channel = event.channel;
@@ -1002,7 +1026,6 @@ export class RoolChannel extends EventEmitter {
1002
1026
  }
1003
1027
  break;
1004
1028
  case 'conversation_updated':
1005
- // Only update if it's our channel
1006
1029
  if (event.channelId === this._channelId && event.conversationId) {
1007
1030
  if (!this._channel) {
1008
1031
  this._channel = {
@@ -1013,17 +1036,13 @@ export class RoolChannel extends EventEmitter {
1013
1036
  }
1014
1037
  const prev = this._channel.conversations[event.conversationId];
1015
1038
  if (event.conversation) {
1016
- // Update or create conversation in local cache
1017
1039
  this._channel.conversations[event.conversationId] = event.conversation;
1018
1040
  }
1019
1041
  else {
1020
- // Conversation was deleted
1021
1042
  delete this._channel.conversations[event.conversationId];
1022
1043
  }
1023
- // Skip emit if data is unchanged (e.g. echo of our own optimistic update)
1024
1044
  if (JSON.stringify(prev) === JSON.stringify(event.conversation))
1025
1045
  break;
1026
- // Auto-advance active leaf if someone continued our current branch
1027
1046
  if (event.conversation && !Array.isArray(event.conversation.interactions)) {
1028
1047
  const currentLeaf = this._getActiveLeafImpl(event.conversationId);
1029
1048
  if (currentLeaf) {
@@ -1035,13 +1054,11 @@ export class RoolChannel extends EventEmitter {
1035
1054
  }
1036
1055
  }
1037
1056
  }
1038
- // Emit the new conversationUpdated event
1039
1057
  this.emit('conversationUpdated', {
1040
1058
  conversationId: event.conversationId,
1041
1059
  channelId: event.channelId,
1042
1060
  source: changeSource,
1043
1061
  });
1044
- // Backward compat: also emit channelUpdated when the active conversation updates
1045
1062
  if (event.conversationId === this._conversationId) {
1046
1063
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1047
1064
  }
@@ -1052,87 +1069,75 @@ export class RoolChannel extends EventEmitter {
1052
1069
  break;
1053
1070
  }
1054
1071
  }
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);
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);
1066
1078
  if (pending !== undefined) {
1067
- // This is our own mutation echoed back
1068
- this._pendingMutations.delete(objectId);
1079
+ this._pendingMutations.delete(location);
1069
1080
  if (pending !== null) {
1070
- // It was a create — already emitted objectCreated optimistically.
1081
+ // Already emitted objectCreated optimistically.
1071
1082
  // Emit objectUpdated only if AI resolved placeholders (data changed).
1072
1083
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1073
- this.emit('objectUpdated', { objectId, object, source });
1084
+ this.emit('objectUpdated', { location, object, source });
1074
1085
  }
1075
1086
  }
1076
1087
  }
1077
1088
  else {
1078
- // Remote event emit normally
1079
- this.emit('objectCreated', { objectId, object, source });
1089
+ this.emit('objectCreated', { location, object, source });
1080
1090
  }
1081
1091
  }
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);
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);
1093
1097
  if (pending !== undefined) {
1094
- // This is our own mutation echoed back
1095
- this._pendingMutations.delete(objectId);
1098
+ this._pendingMutations.delete(location);
1096
1099
  if (pending !== null) {
1097
- // Already emitted objectUpdated optimistically.
1098
- // Emit again only if data changed (AI resolved placeholders).
1099
1100
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1100
- this.emit('objectUpdated', { objectId, object, source });
1101
+ this.emit('objectUpdated', { location, object, source });
1101
1102
  }
1102
1103
  }
1103
1104
  }
1104
1105
  else {
1105
- // Remote event
1106
- this.emit('objectUpdated', { objectId, object, source });
1106
+ this.emit('objectUpdated', { location, object, source });
1107
1107
  }
1108
1108
  }
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);
1109
+ /** @internal */
1110
+ _handleObjectDeleted(location, source) {
1111
+ this._objectLocations = this._objectLocations.filter(l => l !== location);
1112
+ const pending = this._pendingMutations.get(location);
1118
1113
  if (pending !== undefined) {
1119
- // This is our own delete echoed back — already emitted
1120
- this._pendingMutations.delete(objectId);
1114
+ this._pendingMutations.delete(location);
1121
1115
  }
1122
1116
  else {
1123
- // Remote event
1124
- 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 });
1125
1136
  }
1126
1137
  }
1127
1138
  }
1128
1139
  /**
1129
1140
  * 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
1141
  */
1137
1142
  export class ConversationHandle {
1138
1143
  /** @internal */
@@ -1178,16 +1183,20 @@ export class ConversationHandle {
1178
1183
  return this._channel._findObjectsImpl(options, this._conversationId);
1179
1184
  }
1180
1185
  /** Create a new object. */
1181
- async createObject(options) {
1182
- return this._channel._createObjectImpl(options, this._conversationId);
1186
+ async createObject(collection, body, options) {
1187
+ return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1183
1188
  }
1184
1189
  /** Update an existing object. */
1185
- async updateObject(objectId, options) {
1186
- 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);
1187
1196
  }
1188
- /** Delete objects by IDs. */
1189
- async deleteObjects(objectIds) {
1190
- return this._channel._deleteObjectsImpl(objectIds, this._conversationId);
1197
+ /** Delete objects by location. */
1198
+ async deleteObjects(locations) {
1199
+ return this._channel._deleteObjectsImpl(locations, this._conversationId);
1191
1200
  }
1192
1201
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1193
1202
  async prompt(text, options) {