@rool-dev/sdk 0.9.0-dev.10b2f38 → 0.9.0-dev.14c5d65

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 +357 -165
  2. package/dist/channel.d.ts +94 -184
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +356 -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 +57 -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 +112 -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 +30 -0
  23. package/dist/machine.d.ts.map +1 -0
  24. package/dist/machine.js +70 -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 +33 -45
  33. package/dist/space.js.map +1 -1
  34. package/dist/subscription.d.ts.map +1 -1
  35. package/dist/subscription.js +9 -12
  36. package/dist/subscription.js.map +1 -1
  37. package/dist/types.d.ts +72 -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 { machineRef } 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,43 @@ 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
+ attachmentRefs = await Promise.all(attachments.map((file) => this.uploadAttachment(file, conversationId)));
777
819
  }
778
820
  // Auto-continue from active leaf if no explicit parent provided
779
821
  const parentInteractionId = explicitParent !== undefined
780
822
  ? explicitParent
781
823
  : (this._getActiveLeafImpl(conversationId) ?? null);
782
- const interactionId = generateEntityId();
783
824
  // Optimistically set active leaf before the server call.
784
825
  this._activeLeaves.set(conversationId, interactionId);
785
826
  let onAbort;
786
827
  if (signal) {
787
828
  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
829
  this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
791
830
  }
792
831
  else {
@@ -798,29 +837,33 @@ export class RoolChannel extends EventEmitter {
798
837
  }
799
838
  let result;
800
839
  try {
801
- result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, { ...rest, attachmentUrls, interactionId, parentInteractionId });
840
+ result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
841
+ ...rest,
842
+ locations: locations?.map(normalizeLocation),
843
+ attachmentRefs,
844
+ interactionId,
845
+ parentInteractionId,
846
+ });
802
847
  }
803
848
  finally {
804
849
  if (onAbort)
805
850
  signal.removeEventListener('abort', onAbort);
806
851
  }
807
852
  // 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
853
  const objects = [];
810
854
  const missing = [];
811
- for (const id of result.modifiedObjectIds) {
812
- const buffered = this._objectBuffer.get(id);
855
+ for (const location of result.modifiedObjectLocations) {
856
+ const buffered = this._objectBuffer.get(location);
813
857
  if (buffered) {
814
- this._objectBuffer.delete(id);
858
+ this._objectBuffer.delete(location);
815
859
  objects.push(buffered);
816
860
  }
817
861
  else {
818
- missing.push(id);
862
+ missing.push(location);
819
863
  }
820
864
  }
821
- // Fetch any objects not yet received via SSE
822
865
  if (missing.length > 0) {
823
- const fetched = await Promise.all(missing.map(id => this.graphqlClient.getObject(this._id, id)));
866
+ const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
824
867
  for (const obj of fetched) {
825
868
  if (obj)
826
869
  objects.push(obj);
@@ -831,119 +874,90 @@ export class RoolChannel extends EventEmitter {
831
874
  objects,
832
875
  };
833
876
  }
834
- /**
835
- * Rename this channel.
836
- */
877
+ /** Rename this channel. */
837
878
  async rename(newName) {
838
- // Optimistic local update
839
879
  const previousName = this._channel?.name;
840
880
  if (this._channel) {
841
881
  this._channel.name = newName;
842
882
  }
843
883
  this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
844
- // Call server
845
884
  try {
846
885
  await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
847
886
  }
848
887
  catch (error) {
849
888
  this.logger.error('[RoolChannel] Failed to rename channel:', error);
850
- // Rollback
851
889
  if (this._channel) {
852
890
  this._channel.name = previousName;
853
891
  }
854
892
  throw error;
855
893
  }
856
894
  }
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
895
  /**
884
896
  * 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
897
  */
891
898
  async fetch(url, init) {
892
- return this.mediaClient.proxyFetch(this._id, url, init);
899
+ return this.restClient.proxyFetch(this._id, url, init);
900
+ }
901
+ async uploadAttachment(file, conversationId) {
902
+ await this.ensureCollection('attachments');
903
+ const directory = `attachments/${conversationId}`;
904
+ await this.ensureCollection(directory);
905
+ const attachment = attachmentBody(file);
906
+ const path = `${directory}/${attachment.filename}`;
907
+ await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
908
+ return machineRef(`/rool-drive/${path}`);
909
+ }
910
+ async ensureCollection(path) {
911
+ // Note: not an object collection, a folder, which is "collection" in webdav land
912
+ const response = await this.webdav.request('MKCOL', path, { collection: true });
913
+ if (response.status === 201 || response.status === 405)
914
+ return;
915
+ throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
893
916
  }
894
917
  /**
895
918
  * 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
919
  * @internal
898
920
  */
899
- _collectObject(objectId) {
921
+ _collectObject(location) {
900
922
  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);
923
+ const buffered = this._objectBuffer.get(location);
903
924
  if (buffered) {
904
- this._objectBuffer.delete(objectId);
925
+ this._objectBuffer.delete(location);
905
926
  resolve(buffered);
906
927
  return;
907
928
  }
908
929
  const timer = setTimeout(() => {
909
- this._objectResolvers.delete(objectId);
930
+ this._objectResolvers.delete(location);
910
931
  // Fallback: try to fetch from server
911
- this.graphqlClient.getObject(this._id, objectId).then(obj => {
932
+ this.graphqlClient.getObject(this._id, location).then(obj => {
912
933
  if (obj) {
913
934
  resolve(obj);
914
935
  }
915
936
  else {
916
- reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
937
+ reject(new Error(`Timeout waiting for object ${location} from SSE`));
917
938
  }
918
939
  }).catch(reject);
919
940
  }, OBJECT_COLLECT_TIMEOUT);
920
- this._objectResolvers.set(objectId, (obj) => {
941
+ this._objectResolvers.set(location, (obj) => {
921
942
  clearTimeout(timer);
922
943
  resolve(obj);
923
944
  });
924
945
  });
925
946
  }
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);
947
+ /** @internal */
948
+ _cancelCollector(location) {
949
+ this._objectResolvers.delete(location);
950
+ this._objectBuffer.delete(location);
933
951
  }
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);
952
+ /** @internal */
953
+ _deliverObject(location, object) {
954
+ const resolver = this._objectResolvers.get(location);
940
955
  if (resolver) {
941
956
  resolver(object);
942
- this._objectResolvers.delete(objectId);
957
+ this._objectResolvers.delete(location);
943
958
  }
944
959
  else {
945
- // Buffer for prompt() or late collectors
946
- this._objectBuffer.set(objectId, object);
960
+ this._objectBuffer.set(location, object);
947
961
  }
948
962
  }
949
963
  /**
@@ -951,7 +965,6 @@ export class RoolChannel extends EventEmitter {
951
965
  * @internal
952
966
  */
953
967
  handleChannelEvent(event) {
954
- // Ignore events after close - the channel is being torn down
955
968
  if (this._closed)
956
969
  return;
957
970
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
@@ -960,23 +973,31 @@ export class RoolChannel extends EventEmitter {
960
973
  // Resync is handled by the client via _applyResyncData.
961
974
  break;
962
975
  case 'object_created':
963
- if (event.objectId && event.object) {
976
+ if (event.location && event.object) {
964
977
  if (event.objectStat)
965
- this._objectStats.set(event.objectId, event.objectStat);
966
- this._handleObjectCreated(event.objectId, event.object, changeSource);
978
+ this._objectStats.set(event.location, event.objectStat);
979
+ this._handleObjectCreated(event.location, event.object, changeSource);
967
980
  }
968
981
  break;
969
982
  case 'object_updated':
970
- if (event.objectId && event.object) {
983
+ if (event.location && event.object) {
971
984
  if (event.objectStat)
972
- this._objectStats.set(event.objectId, event.objectStat);
973
- this._handleObjectUpdated(event.objectId, event.object, changeSource);
985
+ this._objectStats.set(event.location, event.objectStat);
986
+ this._handleObjectUpdated(event.location, event.object, changeSource);
974
987
  }
975
988
  break;
976
989
  case 'object_deleted':
977
- if (event.objectId) {
978
- this._objectStats.delete(event.objectId);
979
- this._handleObjectDeleted(event.objectId, changeSource);
990
+ if (event.location) {
991
+ this._objectStats.delete(event.location);
992
+ this._handleObjectDeleted(event.location, changeSource);
993
+ }
994
+ break;
995
+ case 'object_moved':
996
+ if (event.from && event.to && event.object) {
997
+ this._objectStats.delete(event.from);
998
+ if (event.objectStat)
999
+ this._objectStats.set(event.to, event.objectStat);
1000
+ this._handleObjectMoved(event.from, event.to, event.object, changeSource);
980
1001
  }
981
1002
  break;
982
1003
  case 'schema_updated':
@@ -992,7 +1013,6 @@ export class RoolChannel extends EventEmitter {
992
1013
  }
993
1014
  break;
994
1015
  case 'channel_updated':
995
- // Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
996
1016
  if (event.channelId === this._channelId && event.channel) {
997
1017
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
998
1018
  this._channel = event.channel;
@@ -1002,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
1002
1022
  }
1003
1023
  break;
1004
1024
  case 'conversation_updated':
1005
- // Only update if it's our channel
1006
1025
  if (event.channelId === this._channelId && event.conversationId) {
1007
1026
  if (!this._channel) {
1008
1027
  this._channel = {
@@ -1013,17 +1032,13 @@ export class RoolChannel extends EventEmitter {
1013
1032
  }
1014
1033
  const prev = this._channel.conversations[event.conversationId];
1015
1034
  if (event.conversation) {
1016
- // Update or create conversation in local cache
1017
1035
  this._channel.conversations[event.conversationId] = event.conversation;
1018
1036
  }
1019
1037
  else {
1020
- // Conversation was deleted
1021
1038
  delete this._channel.conversations[event.conversationId];
1022
1039
  }
1023
- // Skip emit if data is unchanged (e.g. echo of our own optimistic update)
1024
1040
  if (JSON.stringify(prev) === JSON.stringify(event.conversation))
1025
1041
  break;
1026
- // Auto-advance active leaf if someone continued our current branch
1027
1042
  if (event.conversation && !Array.isArray(event.conversation.interactions)) {
1028
1043
  const currentLeaf = this._getActiveLeafImpl(event.conversationId);
1029
1044
  if (currentLeaf) {
@@ -1035,13 +1050,11 @@ export class RoolChannel extends EventEmitter {
1035
1050
  }
1036
1051
  }
1037
1052
  }
1038
- // Emit the new conversationUpdated event
1039
1053
  this.emit('conversationUpdated', {
1040
1054
  conversationId: event.conversationId,
1041
1055
  channelId: event.channelId,
1042
1056
  source: changeSource,
1043
1057
  });
1044
- // Backward compat: also emit channelUpdated when the active conversation updates
1045
1058
  if (event.conversationId === this._conversationId) {
1046
1059
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1047
1060
  }
@@ -1052,87 +1065,75 @@ export class RoolChannel extends EventEmitter {
1052
1065
  break;
1053
1066
  }
1054
1067
  }
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);
1068
+ /** @internal */
1069
+ _handleObjectCreated(location, object, source) {
1070
+ this._deliverObject(location, object);
1071
+ // Maintain local location list — prepend (most recently modified first)
1072
+ this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1073
+ const pending = this._pendingMutations.get(location);
1066
1074
  if (pending !== undefined) {
1067
- // This is our own mutation echoed back
1068
- this._pendingMutations.delete(objectId);
1075
+ this._pendingMutations.delete(location);
1069
1076
  if (pending !== null) {
1070
- // It was a create — already emitted objectCreated optimistically.
1077
+ // Already emitted objectCreated optimistically.
1071
1078
  // Emit objectUpdated only if AI resolved placeholders (data changed).
1072
1079
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1073
- this.emit('objectUpdated', { objectId, object, source });
1080
+ this.emit('objectUpdated', { location, object, source });
1074
1081
  }
1075
1082
  }
1076
1083
  }
1077
1084
  else {
1078
- // Remote event emit normally
1079
- this.emit('objectCreated', { objectId, object, source });
1085
+ this.emit('objectCreated', { location, object, source });
1080
1086
  }
1081
1087
  }
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);
1088
+ /** @internal */
1089
+ _handleObjectUpdated(location, object, source) {
1090
+ this._deliverObject(location, object);
1091
+ this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1092
+ const pending = this._pendingMutations.get(location);
1093
1093
  if (pending !== undefined) {
1094
- // This is our own mutation echoed back
1095
- this._pendingMutations.delete(objectId);
1094
+ this._pendingMutations.delete(location);
1096
1095
  if (pending !== null) {
1097
- // Already emitted objectUpdated optimistically.
1098
- // Emit again only if data changed (AI resolved placeholders).
1099
1096
  if (JSON.stringify(pending) !== JSON.stringify(object)) {
1100
- this.emit('objectUpdated', { objectId, object, source });
1097
+ this.emit('objectUpdated', { location, object, source });
1101
1098
  }
1102
1099
  }
1103
1100
  }
1104
1101
  else {
1105
- // Remote event
1106
- this.emit('objectUpdated', { objectId, object, source });
1102
+ this.emit('objectUpdated', { location, object, source });
1107
1103
  }
1108
1104
  }
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);
1105
+ /** @internal */
1106
+ _handleObjectDeleted(location, source) {
1107
+ this._objectLocations = this._objectLocations.filter(l => l !== location);
1108
+ const pending = this._pendingMutations.get(location);
1118
1109
  if (pending !== undefined) {
1119
- // This is our own delete echoed back — already emitted
1120
- this._pendingMutations.delete(objectId);
1110
+ this._pendingMutations.delete(location);
1121
1111
  }
1122
1112
  else {
1123
- // Remote event
1124
- this.emit('objectDeleted', { objectId, source });
1113
+ this.emit('objectDeleted', { location, source });
1114
+ }
1115
+ }
1116
+ /** @internal */
1117
+ _handleObjectMoved(from, to, object, source) {
1118
+ this._deliverObject(to, object);
1119
+ // Drop old location, insert new one at the front.
1120
+ this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
1121
+ const pending = this._pendingMutations.get(to);
1122
+ if (pending !== undefined) {
1123
+ this._pendingMutations.delete(to);
1124
+ if (pending !== null) {
1125
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
1126
+ this.emit('objectUpdated', { location: to, object, source });
1127
+ }
1128
+ }
1129
+ }
1130
+ else {
1131
+ this.emit('objectMoved', { from, to, object, source });
1125
1132
  }
1126
1133
  }
1127
1134
  }
1128
1135
  /**
1129
1136
  * 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
1137
  */
1137
1138
  export class ConversationHandle {
1138
1139
  /** @internal */
@@ -1178,16 +1179,20 @@ export class ConversationHandle {
1178
1179
  return this._channel._findObjectsImpl(options, this._conversationId);
1179
1180
  }
1180
1181
  /** Create a new object. */
1181
- async createObject(options) {
1182
- return this._channel._createObjectImpl(options, this._conversationId);
1182
+ async createObject(collection, body, options) {
1183
+ return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1183
1184
  }
1184
1185
  /** Update an existing object. */
1185
- async updateObject(objectId, options) {
1186
- return this._channel._updateObjectImpl(objectId, options, this._conversationId);
1186
+ async updateObject(location, options) {
1187
+ return this._channel._updateObjectImpl(location, options, this._conversationId);
1188
+ }
1189
+ /** Move (rename/relocate) an object. */
1190
+ async moveObject(from, to, options) {
1191
+ return this._channel._moveObjectImpl(from, to, options, this._conversationId);
1187
1192
  }
1188
- /** Delete objects by IDs. */
1189
- async deleteObjects(objectIds) {
1190
- return this._channel._deleteObjectsImpl(objectIds, this._conversationId);
1193
+ /** Delete objects by location. */
1194
+ async deleteObjects(locations) {
1195
+ return this._channel._deleteObjectsImpl(locations, this._conversationId);
1191
1196
  }
1192
1197
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1193
1198
  async prompt(text, options) {