@rool-dev/sdk 0.9.0-dev.9cb655b → 0.9.0-dev.bab1e95

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