@rool-dev/sdk 0.10.1 → 0.10.2-dev.0bf8edb

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 (58) hide show
  1. package/README.md +79 -179
  2. package/dist/channel.d.ts +41 -129
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +220 -394
  5. package/dist/channel.js.map +1 -1
  6. package/dist/client.d.ts +3 -55
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +7 -93
  9. package/dist/client.js.map +1 -1
  10. package/dist/graphql.d.ts +4 -46
  11. package/dist/graphql.d.ts.map +1 -1
  12. package/dist/graphql.js +28 -233
  13. package/dist/graphql.js.map +1 -1
  14. package/dist/index.d.ts +3 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/path.d.ts +6 -0
  19. package/dist/path.d.ts.map +1 -0
  20. package/dist/path.js +47 -0
  21. package/dist/path.js.map +1 -0
  22. package/dist/reroute.d.ts +22 -0
  23. package/dist/reroute.d.ts.map +1 -0
  24. package/dist/reroute.js +61 -0
  25. package/dist/reroute.js.map +1 -0
  26. package/dist/rest.d.ts +9 -0
  27. package/dist/rest.d.ts.map +1 -1
  28. package/dist/rest.js +33 -1
  29. package/dist/rest.js.map +1 -1
  30. package/dist/router.d.ts.map +1 -1
  31. package/dist/router.js +25 -10
  32. package/dist/router.js.map +1 -1
  33. package/dist/space.d.ts +10 -17
  34. package/dist/space.d.ts.map +1 -1
  35. package/dist/space.js +79 -55
  36. package/dist/space.js.map +1 -1
  37. package/dist/subscription.d.ts.map +1 -1
  38. package/dist/subscription.js +40 -32
  39. package/dist/subscription.js.map +1 -1
  40. package/dist/types.d.ts +52 -217
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/webdav.d.ts +44 -21
  43. package/dist/webdav.d.ts.map +1 -1
  44. package/dist/webdav.js +94 -57
  45. package/dist/webdav.js.map +1 -1
  46. package/package.json +2 -1
  47. package/dist/apps.d.ts +0 -30
  48. package/dist/apps.d.ts.map +0 -1
  49. package/dist/apps.js +0 -81
  50. package/dist/apps.js.map +0 -1
  51. package/dist/locations.d.ts +0 -34
  52. package/dist/locations.d.ts.map +0 -1
  53. package/dist/locations.js +0 -90
  54. package/dist/locations.js.map +0 -1
  55. package/dist/machine.d.ts +0 -16
  56. package/dist/machine.d.ts.map +0 -1
  57. package/dist/machine.js +0 -51
  58. package/dist/machine.js.map +0 -1
package/dist/channel.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { EventEmitter } from './event-emitter.js';
2
- import { generateBasename, loc, normalizeLocation, parseLocation } from './locations.js';
3
- import { resolveMachineResource } from './machine.js';
4
- // 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
2
+ import { WebDAVError } from './webdav.js';
3
+ import { isObjectPath, machinePath, machineUri } from './path.js';
4
+ // 6-character alphanumeric ID — used for object names, interactionIds, conversationIds, etc.
5
5
  const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
6
+ const GET_OBJECTS_CHUNK_SIZE = 500;
6
7
  export function generateEntityId() {
7
8
  let result = '';
8
9
  for (let i = 0; i < 6; i++) {
@@ -42,6 +43,45 @@ function findDefaultLeaf(interactions) {
42
43
  }
43
44
  return best?.id;
44
45
  }
46
+ function objectPath(input) {
47
+ const path = machinePath(input);
48
+ if (!isObjectPath(path)) {
49
+ throw new Error(`Object path must be /space/<collection>/<name>.json without dotfiles: ${input}`);
50
+ }
51
+ return path;
52
+ }
53
+ function collectionPath(name) {
54
+ return machinePath(`/space/${name}`);
55
+ }
56
+ function schemaPath(name) {
57
+ return `${collectionPath(name)}/.schema.json`;
58
+ }
59
+ function objectFromBody(path, body) {
60
+ return { path, body };
61
+ }
62
+ function jsonObject(value, label) {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
64
+ throw new Error(`${label} must be a JSON object`);
65
+ }
66
+ return value;
67
+ }
68
+ function patchBody(current, patch) {
69
+ const next = { ...current };
70
+ for (const [key, value] of Object.entries(patch)) {
71
+ if (value === null || value === undefined)
72
+ delete next[key];
73
+ else
74
+ next[key] = value;
75
+ }
76
+ return next;
77
+ }
78
+ function collectionDef(input, options) {
79
+ const base = Array.isArray(input)
80
+ ? { fields: input }
81
+ : { fields: input.fields, schemaOrgType: input.schemaOrgType };
82
+ const schemaOrgType = options?.schemaOrgType ?? base.schemaOrgType;
83
+ return schemaOrgType ? { fields: base.fields, schemaOrgType } : { fields: base.fields };
84
+ }
45
85
  function attachmentBody(file) {
46
86
  if (isFile(file)) {
47
87
  return {
@@ -59,7 +99,7 @@ function attachmentBody(file) {
59
99
  };
60
100
  }
61
101
  return {
62
- filename: safeAttachmentFilename('attachment', file.contentType),
102
+ filename: safeAttachmentFilename(file.filename ?? 'attachment', file.contentType),
63
103
  contentType: file.contentType,
64
104
  body: base64Body(file.data),
65
105
  };
@@ -115,8 +155,6 @@ function base64Body(data) {
115
155
  bytes[i] = binary.charCodeAt(i);
116
156
  return bytes.buffer;
117
157
  }
118
- // Default timeout for waiting on SSE object events (30 seconds)
119
- const OBJECT_COLLECT_TIMEOUT = 30000;
120
158
  /**
121
159
  * A channel is a space + channelId pair.
122
160
  *
@@ -124,10 +162,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
124
162
  * at open time and cannot be changed. To use a different channel,
125
163
  * open a second one.
126
164
  *
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
130
- * arrive via SSE semantic events and are emitted as SDK events.
165
+ * Objects are addressed by machine path (`/space/.../*.json`).
166
+ * Only schema, metadata, object stats, and the channel's own history are cached
167
+ * locally. Object bodies are fetched on demand. Object/file reactivity is
168
+ * exposed at the space level via WebDAV sync notifications.
131
169
  */
132
170
  export class RoolChannel extends EventEmitter {
133
171
  _id;
@@ -143,21 +181,13 @@ export class RoolChannel extends EventEmitter {
143
181
  webdav;
144
182
  onCloseCallback;
145
183
  logger;
146
- // Local cache for bounded data (schema, metadata, own channel, object locations, stats)
184
+ // Local cache for bounded data (schema, metadata, own channel, object stats)
147
185
  _meta;
148
186
  _schema;
149
187
  _channel;
150
- _objectLocations;
151
188
  _objectStats;
152
189
  // Active leaf per conversation (client-side tree cursor)
153
190
  _activeLeaves = new Map();
154
- // Object collection: tracks pending local mutations (by location) for dedup
155
- // Maps location → optimistic object (for create/update) or null (for delete)
156
- _pendingMutations = new Map();
157
- // Resolvers waiting for object data from SSE events, keyed by location
158
- _objectResolvers = new Map();
159
- // Buffer for object data that arrived before a collector was registered, keyed by location
160
- _objectBuffer = new Map();
161
191
  constructor(config) {
162
192
  super();
163
193
  this._id = config.id;
@@ -176,8 +206,7 @@ export class RoolChannel extends EventEmitter {
176
206
  // Initialize local cache from server data
177
207
  this._meta = config.meta;
178
208
  this._schema = config.schema;
179
- this._channel = config.channel;
180
- this._objectLocations = config.objectLocations;
209
+ this._channel = config.channel ?? undefined;
181
210
  this._objectStats = new Map(Object.entries(config.objectStats));
182
211
  }
183
212
  /**
@@ -198,7 +227,6 @@ export class RoolChannel extends EventEmitter {
198
227
  return;
199
228
  this._meta = data.meta;
200
229
  this._schema = data.schema;
201
- this._objectLocations = data.objectLocations;
202
230
  this._objectStats = new Map(Object.entries(data.objectStats));
203
231
  if (data.channel)
204
232
  this._channel = data.channel;
@@ -244,24 +272,6 @@ export class RoolChannel extends EventEmitter {
244
272
  get isReadOnly() {
245
273
  return this._role === 'viewer';
246
274
  }
247
- /**
248
- * Get the extension URL if this channel was created via installExtension, or null.
249
- */
250
- get extensionUrl() {
251
- return this._channel?.extensionUrl ?? null;
252
- }
253
- /**
254
- * Get the extension ID if this channel has an installed extension, or null.
255
- */
256
- get extensionId() {
257
- return this._channel?.extensionId ?? null;
258
- }
259
- /**
260
- * Get the extension manifest if this channel has an installed extension, or null.
261
- */
262
- get manifest() {
263
- return this._channel?.manifest ?? null;
264
- }
265
275
  /**
266
276
  * Get the active branch of the current conversation as a flat array (root → leaf).
267
277
  * Walks from the active leaf up through parentId pointers.
@@ -376,10 +386,6 @@ export class RoolChannel extends EventEmitter {
376
386
  close() {
377
387
  this._closed = true;
378
388
  this.onCloseCallback();
379
- // Clean up pending object collectors
380
- this._objectResolvers.clear();
381
- this._objectBuffer.clear();
382
- this._pendingMutations.clear();
383
389
  this.removeAllListeners();
384
390
  }
385
391
  /**
@@ -413,208 +419,165 @@ export class RoolChannel extends EventEmitter {
413
419
  async clearHistory() {
414
420
  await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
415
421
  }
416
- /**
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>`).
421
- */
422
- async getObject(location) {
423
- return this.graphqlClient.getObject(this._id, normalizeLocation(location));
424
- }
425
- /**
426
- * Get an object's stat (audit information).
427
- * Returns the cached stat or undefined if not known.
428
- */
429
- stat(location) {
430
- return this._objectStats.get(normalizeLocation(location));
431
- }
432
- /**
433
- * Find objects using structured filters and/or natural language.
434
- */
435
- async findObjects(options) {
436
- return this._findObjectsImpl(options, this._conversationId);
422
+ davHeaders(conversationId, interactionId) {
423
+ const headers = new Headers({
424
+ 'X-Rool-Channel-Id': this._channelId,
425
+ 'X-Rool-Conversation-Id': conversationId,
426
+ });
427
+ if (interactionId)
428
+ headers.set('X-Rool-Interaction-Id', interactionId);
429
+ return headers;
437
430
  }
438
- /** @internal */
439
- _findObjectsImpl(options, conversationId) {
440
- const normalized = {
441
- ...options,
442
- locations: options.locations?.map(normalizeLocation),
443
- };
444
- return this.graphqlClient.findObjects(this._id, normalized, this._channelId, conversationId);
431
+ async readObject(path) {
432
+ const canonical = objectPath(path);
433
+ try {
434
+ const response = await this.webdav.get(canonical);
435
+ const body = jsonObject(await response.json(), `Object ${canonical}`);
436
+ return { object: objectFromBody(canonical, body), etag: response.headers.get('ETag') };
437
+ }
438
+ catch (error) {
439
+ if (error instanceof WebDAVError && error.status === 404)
440
+ return undefined;
441
+ if (error instanceof SyntaxError)
442
+ throw new Error(`Object ${canonical} did not contain valid JSON`);
443
+ throw error;
444
+ }
445
445
  }
446
- /**
447
- * Get all object locations (sync, from local cache).
448
- * The list is loaded on open and kept current via SSE events.
449
- */
450
- getObjectLocations(options) {
451
- let locs = this._objectLocations;
452
- if (options?.order === 'asc') {
453
- locs = [...locs].reverse();
446
+ /** Get an object JSON file by machine path. Fetches from the server on each call. */
447
+ async getObject(path) {
448
+ return (await this.readObject(path))?.object;
449
+ }
450
+ /** Get object JSON files by machine path in bulk. Duplicate paths are fetched once. */
451
+ async getObjects(paths) {
452
+ const canonical = [];
453
+ const seen = new Set();
454
+ for (const path of paths) {
455
+ const normalized = objectPath(path);
456
+ if (seen.has(normalized))
457
+ continue;
458
+ seen.add(normalized);
459
+ canonical.push(normalized);
454
460
  }
455
- if (options?.limit !== undefined) {
456
- locs = locs.slice(0, options.limit);
461
+ const result = { objects: [], missing: [] };
462
+ for (let i = 0; i < canonical.length; i += GET_OBJECTS_CHUNK_SIZE) {
463
+ const chunk = canonical.slice(i, i + GET_OBJECTS_CHUNK_SIZE);
464
+ const partial = await this.restClient.getObjects(this._id, chunk);
465
+ result.objects.push(...partial.objects);
466
+ result.missing.push(...partial.missing);
457
467
  }
458
- return locs;
468
+ return result;
459
469
  }
460
- /**
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.
467
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
468
- * @returns The created object and a status message.
469
- */
470
- async createObject(collection, body, options) {
471
- return this._createObjectImpl(collection, body, options, this._conversationId);
470
+ /** Get an object's cached audit information. */
471
+ stat(path) {
472
+ return this._objectStats.get(objectPath(path));
473
+ }
474
+ /** Create or replace an object JSON file at an exact machine path. */
475
+ async putObject(path, body) {
476
+ return this._putObjectImpl(path, body, this._conversationId);
472
477
  }
473
478
  /** @internal */
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' });
479
+ async _putObjectImpl(path, body, conversationId) {
480
+ const canonical = objectPath(path);
481
+ const optimistic = objectFromBody(canonical, body);
480
482
  try {
481
483
  const interactionId = generateEntityId();
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 };
484
+ await this.webdav.put(canonical, JSON.stringify(body), {
485
+ contentType: 'application/json',
486
+ headers: this.davHeaders(conversationId, interactionId),
487
+ });
488
+ const fresh = await this.getObject(canonical) ?? optimistic;
489
+ return { object: fresh, message: `Put ${canonical}` };
485
490
  }
486
491
  catch (error) {
487
- this.logger.error('[RoolChannel] Failed to create object:', error);
488
- this._pendingMutations.delete(location);
489
- this._cancelCollector(location);
492
+ this.logger.error('[RoolChannel] Failed to put object:', error);
490
493
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
491
- this.emit('reset', { source: 'system' });
492
494
  throw error;
493
495
  }
494
496
  }
495
- /**
496
- * Update an existing object.
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.
501
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
502
- */
503
- async updateObject(location, options) {
504
- return this._updateObjectImpl(location, options, this._conversationId);
497
+ /** Patch an existing object. Null or undefined deletes a field. */
498
+ async patchObject(path, options) {
499
+ return this._patchObjectImpl(path, options, this._conversationId);
505
500
  }
506
501
  /** @internal */
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;
512
- if (data) {
513
- serverPatch = {};
514
- for (const [key, value] of Object.entries(data)) {
515
- serverPatch[key] = value === undefined ? null : value;
516
- }
517
- }
518
- // Emit optimistic event if we have data changes
519
- if (data) {
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' });
524
- }
502
+ async _patchObjectImpl(path, options, conversationId) {
503
+ const canonical = objectPath(path);
504
+ const data = options.data ?? {};
505
+ const current = await this.readObject(canonical);
506
+ if (!current)
507
+ throw new Error(`Object ${canonical} not found`);
508
+ const body = patchBody(current.object.body, data);
509
+ const optimistic = objectFromBody(canonical, body);
525
510
  try {
526
511
  const interactionId = generateEntityId();
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,
512
+ await this.webdav.put(canonical, JSON.stringify(body), {
513
+ contentType: 'application/json',
514
+ ifMatch: current.etag ?? undefined,
515
+ headers: this.davHeaders(conversationId, interactionId),
532
516
  });
533
- const fresh = object ?? await this._collectObject(canonical);
534
- return { object: fresh, message };
517
+ const fresh = await this.getObject(canonical) ?? optimistic;
518
+ return { object: fresh, message: `Patched ${canonical}` };
535
519
  }
536
520
  catch (error) {
537
- this.logger.error('[RoolChannel] Failed to update object:', error);
538
- this._pendingMutations.delete(canonical);
539
- this._cancelCollector(canonical);
521
+ this.logger.error('[RoolChannel] Failed to patch object:', error);
540
522
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
541
- this.emit('reset', { source: 'system' });
542
523
  throw error;
543
524
  }
544
525
  }
545
- /**
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
- */
526
+ /** Move an object JSON file to a new machine path, optionally replacing its body. */
554
527
  async moveObject(from, to, options) {
555
528
  return this._moveObjectImpl(from, to, options, this._conversationId);
556
529
  }
557
530
  /** @internal */
558
531
  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' });
532
+ const fromPath = objectPath(from);
533
+ const toPath = objectPath(to);
534
+ const optimistic = objectFromBody(toPath, options?.body ?? {});
571
535
  try {
572
536
  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,
537
+ await this.webdav.move(fromPath, toPath, {
538
+ headers: this.davHeaders(conversationId, interactionId),
577
539
  });
578
- const fresh = object ?? await this._collectObject(toLoc);
579
- return { object: fresh, message };
540
+ if (options?.body) {
541
+ await this.webdav.put(toPath, JSON.stringify(options.body), {
542
+ contentType: 'application/json',
543
+ headers: this.davHeaders(conversationId, interactionId),
544
+ });
545
+ }
546
+ this._objectStats.delete(fromPath);
547
+ const fresh = await this.getObject(toPath) ?? optimistic;
548
+ return { object: fresh, message: `Moved ${fromPath} to ${toPath}` };
580
549
  }
581
550
  catch (error) {
582
551
  this.logger.error('[RoolChannel] Failed to move object:', error);
583
- this._pendingMutations.delete(toLoc);
584
- this._cancelCollector(toLoc);
585
552
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
586
- this.emit('reset', { source: 'system' });
587
553
  throw error;
588
554
  }
589
555
  }
590
- /**
591
- * Delete objects by location.
592
- * Other objects that reference deleted objects will retain stale ref values.
593
- */
594
- async deleteObjects(locations) {
595
- return this._deleteObjectsImpl(locations, this._conversationId);
556
+ /** Delete object JSON files by machine path. */
557
+ async deleteObjects(paths) {
558
+ return this._deleteObjectsImpl(paths, this._conversationId);
559
+ }
560
+ /** @deprecated Use deleteObjects instead. */
561
+ async deletePaths(paths) {
562
+ return this.deleteObjects(paths);
596
563
  }
597
564
  /** @internal */
598
- async _deleteObjectsImpl(locations, conversationId) {
599
- if (locations.length === 0)
565
+ async _deleteObjectsImpl(paths, conversationId) {
566
+ if (paths.length === 0)
600
567
  return;
601
- const canonical = locations.map(normalizeLocation);
602
- // Track for dedup and emit optimistic events
603
- for (const location of canonical) {
604
- this._pendingMutations.set(location, null);
605
- this.emit('objectDeleted', { location, source: 'local_user' });
606
- }
568
+ const canonical = paths.map(objectPath);
607
569
  try {
608
570
  const interactionId = generateEntityId();
609
- await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
571
+ for (const path of canonical) {
572
+ await this.webdav.delete(path, {
573
+ headers: this.davHeaders(conversationId, interactionId),
574
+ });
575
+ this._objectStats.delete(path);
576
+ }
610
577
  }
611
578
  catch (error) {
612
- this.logger.error('[RoolChannel] Failed to delete objects:', error);
613
- for (const location of canonical) {
614
- this._pendingMutations.delete(location);
615
- }
579
+ this.logger.error('[RoolChannel] Failed to delete paths:', error);
616
580
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
617
- this.emit('reset', { source: 'system' });
618
581
  throw error;
619
582
  }
620
583
  }
@@ -623,19 +586,25 @@ export class RoolChannel extends EventEmitter {
623
586
  return this._schema;
624
587
  }
625
588
  /** Create a new collection schema. */
626
- async createCollection(name, fields) {
627
- return this._createCollectionImpl(name, fields, this._conversationId);
589
+ async createCollection(name, fields, options) {
590
+ return this._createCollectionImpl(name, fields, options, this._conversationId);
628
591
  }
629
592
  /** @internal */
630
- async _createCollectionImpl(name, fields, conversationId) {
593
+ async _createCollectionImpl(name, fields, options, conversationId) {
631
594
  if (this._schema[name]) {
632
595
  throw new Error(`Collection "${name}" already exists`);
633
596
  }
634
597
  // Optimistic local update
635
- const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
598
+ const optimisticDef = collectionDef(fields, options);
636
599
  this._schema[name] = optimisticDef;
637
600
  try {
638
- return await this.graphqlClient.createCollection(this._id, name, fields, this._channelId, conversationId);
601
+ await this.webdav.mkcol(collectionPath(name), { headers: this.davHeaders(conversationId, generateEntityId()) });
602
+ await this.webdav.put(schemaPath(name), JSON.stringify(optimisticDef), {
603
+ contentType: 'application/json',
604
+ headers: this.davHeaders(conversationId, generateEntityId()),
605
+ });
606
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
607
+ return optimisticDef;
639
608
  }
640
609
  catch (error) {
641
610
  this.logger.error('[RoolChannel] Failed to create collection:', error);
@@ -644,18 +613,24 @@ export class RoolChannel extends EventEmitter {
644
613
  }
645
614
  }
646
615
  /** Alter an existing collection schema, replacing its field definitions. */
647
- async alterCollection(name, fields) {
648
- return this._alterCollectionImpl(name, fields, this._conversationId);
616
+ async alterCollection(name, fields, options) {
617
+ return this._alterCollectionImpl(name, fields, options, this._conversationId);
649
618
  }
650
619
  /** @internal */
651
- async _alterCollectionImpl(name, fields, conversationId) {
620
+ async _alterCollectionImpl(name, fields, options, conversationId) {
652
621
  if (!this._schema[name]) {
653
622
  throw new Error(`Collection "${name}" not found`);
654
623
  }
655
624
  const previous = this._schema[name];
656
- this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
625
+ this._schema[name] = collectionDef(fields, options);
657
626
  try {
658
- return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
627
+ const updated = this._schema[name];
628
+ await this.webdav.put(schemaPath(name), JSON.stringify(updated), {
629
+ contentType: 'application/json',
630
+ headers: this.davHeaders(conversationId, generateEntityId()),
631
+ });
632
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
633
+ return updated;
659
634
  }
660
635
  catch (error) {
661
636
  this.logger.error('[RoolChannel] Failed to alter collection:', error);
@@ -675,7 +650,8 @@ export class RoolChannel extends EventEmitter {
675
650
  const previous = this._schema[name];
676
651
  delete this._schema[name];
677
652
  try {
678
- await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
653
+ await this.webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(conversationId, generateEntityId()) });
654
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
679
655
  }
680
656
  catch (error) {
681
657
  this.logger.error('[RoolChannel] Failed to drop collection:', error);
@@ -811,12 +787,14 @@ export class RoolChannel extends EventEmitter {
811
787
  }
812
788
  /** @internal */
813
789
  async _promptImpl(prompt, options, conversationId) {
814
- const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
790
+ const { attachments, parentInteractionId: explicitParent, signal, ...rest } = options ?? {};
815
791
  const interactionId = generateEntityId();
816
792
  let attachmentRefs;
817
793
  if (attachments?.length) {
818
- const resources = await Promise.all(attachments.map((file) => this.uploadAttachment(file, conversationId)));
819
- attachmentRefs = resources.map((resource) => `rool-machine:${resource.path.split('/').map(encodeURIComponent).join('/')}`);
794
+ attachmentRefs = await Promise.all(attachments.map(async (attachment) => {
795
+ const path = typeof attachment === 'string' ? machinePath(attachment) : await this.uploadAttachment(attachment, conversationId);
796
+ return machineUri(path);
797
+ }));
820
798
  }
821
799
  // Auto-continue from active leaf if no explicit parent provided
822
800
  const parentInteractionId = explicitParent !== undefined
@@ -840,7 +818,6 @@ export class RoolChannel extends EventEmitter {
840
818
  try {
841
819
  result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
842
820
  ...rest,
843
- locations: locations?.map(normalizeLocation),
844
821
  attachmentRefs,
845
822
  interactionId,
846
823
  parentInteractionId,
@@ -850,25 +827,11 @@ export class RoolChannel extends EventEmitter {
850
827
  if (onAbort)
851
828
  signal.removeEventListener('abort', onAbort);
852
829
  }
853
- // Collect modified objects — they arrive via SSE events during/after the mutation.
854
830
  const objects = [];
855
- const missing = [];
856
- for (const location of result.modifiedObjectLocations) {
857
- const buffered = this._objectBuffer.get(location);
858
- if (buffered) {
859
- this._objectBuffer.delete(location);
860
- objects.push(buffered);
861
- }
862
- else {
863
- missing.push(location);
864
- }
865
- }
866
- if (missing.length > 0) {
867
- const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
868
- for (const obj of fetched) {
869
- if (obj)
870
- objects.push(obj);
871
- }
831
+ const fetched = await Promise.all(result.modifiedObjectPaths.map((path) => this.getObject(path)));
832
+ for (const object of fetched) {
833
+ if (object)
834
+ objects.push(object);
872
835
  }
873
836
  return {
874
837
  message: result.message,
@@ -900,70 +863,20 @@ export class RoolChannel extends EventEmitter {
900
863
  return this.restClient.proxyFetch(this._id, url, init);
901
864
  }
902
865
  async uploadAttachment(file, conversationId) {
903
- await this.ensureCollection('attachments');
904
- const directory = `attachments/${conversationId}`;
866
+ await this.ensureCollection('/rool-drive/attachments');
867
+ const directory = `/rool-drive/attachments/${conversationId}`;
905
868
  await this.ensureCollection(directory);
906
869
  const attachment = attachmentBody(file);
907
870
  const path = `${directory}/${attachment.filename}`;
908
871
  await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
909
- const resource = resolveMachineResource(`/rool-drive/${path}`);
910
- if (!resource)
911
- throw new Error('Failed to resolve uploaded attachment');
912
- return resource;
872
+ return path;
913
873
  }
914
874
  async ensureCollection(path) {
915
- // Note: not an object collection, a folder, which is "collection" in webdav land
916
875
  const response = await this.webdav.request('MKCOL', path, { collection: true });
917
876
  if (response.status === 201 || response.status === 405)
918
877
  return;
919
878
  throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
920
879
  }
921
- /**
922
- * Register a collector that resolves when the object arrives via SSE.
923
- * @internal
924
- */
925
- _collectObject(location) {
926
- return new Promise((resolve, reject) => {
927
- const buffered = this._objectBuffer.get(location);
928
- if (buffered) {
929
- this._objectBuffer.delete(location);
930
- resolve(buffered);
931
- return;
932
- }
933
- const timer = setTimeout(() => {
934
- this._objectResolvers.delete(location);
935
- // Fallback: try to fetch from server
936
- this.graphqlClient.getObject(this._id, location).then(obj => {
937
- if (obj) {
938
- resolve(obj);
939
- }
940
- else {
941
- reject(new Error(`Timeout waiting for object ${location} from SSE`));
942
- }
943
- }).catch(reject);
944
- }, OBJECT_COLLECT_TIMEOUT);
945
- this._objectResolvers.set(location, (obj) => {
946
- clearTimeout(timer);
947
- resolve(obj);
948
- });
949
- });
950
- }
951
- /** @internal */
952
- _cancelCollector(location) {
953
- this._objectResolvers.delete(location);
954
- this._objectBuffer.delete(location);
955
- }
956
- /** @internal */
957
- _deliverObject(location, object) {
958
- const resolver = this._objectResolvers.get(location);
959
- if (resolver) {
960
- resolver(object);
961
- this._objectResolvers.delete(location);
962
- }
963
- else {
964
- this._objectBuffer.set(location, object);
965
- }
966
- }
967
880
  /**
968
881
  * Handle a channel event from the subscription.
969
882
  * @internal
@@ -976,34 +889,6 @@ export class RoolChannel extends EventEmitter {
976
889
  case 'connected':
977
890
  // Resync is handled by the client via _applyResyncData.
978
891
  break;
979
- case 'object_created':
980
- if (event.location && event.object) {
981
- if (event.objectStat)
982
- this._objectStats.set(event.location, event.objectStat);
983
- this._handleObjectCreated(event.location, event.object, changeSource);
984
- }
985
- break;
986
- case 'object_updated':
987
- if (event.location && event.object) {
988
- if (event.objectStat)
989
- this._objectStats.set(event.location, event.objectStat);
990
- this._handleObjectUpdated(event.location, event.object, changeSource);
991
- }
992
- break;
993
- case 'object_deleted':
994
- if (event.location) {
995
- this._objectStats.delete(event.location);
996
- this._handleObjectDeleted(event.location, changeSource);
997
- }
998
- break;
999
- case 'object_moved':
1000
- if (event.from && event.to && event.object) {
1001
- this._objectStats.delete(event.from);
1002
- if (event.objectStat)
1003
- this._objectStats.set(event.to, event.objectStat);
1004
- this._handleObjectMoved(event.from, event.to, event.object, changeSource);
1005
- }
1006
- break;
1007
892
  case 'schema_updated':
1008
893
  if (event.schema) {
1009
894
  this._schema = event.schema;
@@ -1025,6 +910,13 @@ export class RoolChannel extends EventEmitter {
1025
910
  }
1026
911
  }
1027
912
  break;
913
+ case 'channel_deleted':
914
+ if (event.channelId === this._channelId) {
915
+ this._channel = undefined;
916
+ this._activeLeaves.clear();
917
+ this.emit('reset', { source: changeSource });
918
+ }
919
+ break;
1028
920
  case 'conversation_updated':
1029
921
  if (event.channelId === this._channelId && event.conversationId) {
1030
922
  if (!this._channel) {
@@ -1069,72 +961,6 @@ export class RoolChannel extends EventEmitter {
1069
961
  break;
1070
962
  }
1071
963
  }
1072
- /** @internal */
1073
- _handleObjectCreated(location, object, source) {
1074
- this._deliverObject(location, object);
1075
- // Maintain local location list — prepend (most recently modified first)
1076
- this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1077
- const pending = this._pendingMutations.get(location);
1078
- if (pending !== undefined) {
1079
- this._pendingMutations.delete(location);
1080
- if (pending !== null) {
1081
- // Already emitted objectCreated optimistically.
1082
- // Emit objectUpdated only if AI resolved placeholders (data changed).
1083
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1084
- this.emit('objectUpdated', { location, object, source });
1085
- }
1086
- }
1087
- }
1088
- else {
1089
- this.emit('objectCreated', { location, object, source });
1090
- }
1091
- }
1092
- /** @internal */
1093
- _handleObjectUpdated(location, object, source) {
1094
- this._deliverObject(location, object);
1095
- this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1096
- const pending = this._pendingMutations.get(location);
1097
- if (pending !== undefined) {
1098
- this._pendingMutations.delete(location);
1099
- if (pending !== null) {
1100
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1101
- this.emit('objectUpdated', { location, object, source });
1102
- }
1103
- }
1104
- }
1105
- else {
1106
- this.emit('objectUpdated', { location, object, source });
1107
- }
1108
- }
1109
- /** @internal */
1110
- _handleObjectDeleted(location, source) {
1111
- this._objectLocations = this._objectLocations.filter(l => l !== location);
1112
- const pending = this._pendingMutations.get(location);
1113
- if (pending !== undefined) {
1114
- this._pendingMutations.delete(location);
1115
- }
1116
- else {
1117
- this.emit('objectDeleted', { location, source });
1118
- }
1119
- }
1120
- /** @internal */
1121
- _handleObjectMoved(from, to, object, source) {
1122
- this._deliverObject(to, object);
1123
- // Drop old location, insert new one at the front.
1124
- this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
1125
- const pending = this._pendingMutations.get(to);
1126
- if (pending !== undefined) {
1127
- this._pendingMutations.delete(to);
1128
- if (pending !== null) {
1129
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1130
- this.emit('objectUpdated', { location: to, object, source });
1131
- }
1132
- }
1133
- }
1134
- else {
1135
- this.emit('objectMoved', { from, to, object, source });
1136
- }
1137
- }
1138
964
  }
1139
965
  /**
1140
966
  * A lightweight handle for a specific conversation within a channel.
@@ -1178,37 +1004,37 @@ export class ConversationHandle {
1178
1004
  async rename(name) {
1179
1005
  return this._channel._renameConversationImpl(name, this._conversationId);
1180
1006
  }
1181
- /** Find objects using structured filters and/or natural language. */
1182
- async findObjects(options) {
1183
- return this._channel._findObjectsImpl(options, this._conversationId);
1184
- }
1185
- /** Create a new object. */
1186
- async createObject(collection, body, options) {
1187
- return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1007
+ /** Create or replace an object JSON file. */
1008
+ async putObject(path, body) {
1009
+ return this._channel._putObjectImpl(path, body, this._conversationId);
1188
1010
  }
1189
- /** Update an existing object. */
1190
- async updateObject(location, options) {
1191
- return this._channel._updateObjectImpl(location, options, this._conversationId);
1011
+ /** Patch an existing object JSON file. */
1012
+ async patchObject(path, options) {
1013
+ return this._channel._patchObjectImpl(path, options, this._conversationId);
1192
1014
  }
1193
1015
  /** Move (rename/relocate) an object. */
1194
1016
  async moveObject(from, to, options) {
1195
1017
  return this._channel._moveObjectImpl(from, to, options, this._conversationId);
1196
1018
  }
1197
- /** Delete objects by location. */
1198
- async deleteObjects(locations) {
1199
- return this._channel._deleteObjectsImpl(locations, this._conversationId);
1019
+ /** Delete object JSON files by path. */
1020
+ async deleteObjects(paths) {
1021
+ return this._channel._deleteObjectsImpl(paths, this._conversationId);
1022
+ }
1023
+ /** @deprecated Use deleteObjects instead. */
1024
+ async deletePaths(paths) {
1025
+ return this.deleteObjects(paths);
1200
1026
  }
1201
1027
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1202
1028
  async prompt(text, options) {
1203
1029
  return this._channel._promptImpl(text, options, this._conversationId);
1204
1030
  }
1205
1031
  /** Create a new collection schema. */
1206
- async createCollection(name, fields) {
1207
- return this._channel._createCollectionImpl(name, fields, this._conversationId);
1032
+ async createCollection(name, fields, options) {
1033
+ return this._channel._createCollectionImpl(name, fields, options, this._conversationId);
1208
1034
  }
1209
1035
  /** Alter an existing collection schema. */
1210
- async alterCollection(name, fields) {
1211
- return this._channel._alterCollectionImpl(name, fields, this._conversationId);
1036
+ async alterCollection(name, fields, options) {
1037
+ return this._channel._alterCollectionImpl(name, fields, options, this._conversationId);
1212
1038
  }
1213
1039
  /** Drop a collection schema. */
1214
1040
  async dropCollection(name) {