@rool-dev/sdk 0.8.2-dev.02b70e5 → 0.8.2-dev.d82ea25

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 (56) hide show
  1. package/README.md +465 -1005
  2. package/dist/channel.d.ts +93 -248
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +410 -577
  5. package/dist/channel.js.map +1 -1
  6. package/dist/client.d.ts +14 -46
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +31 -124
  9. package/dist/client.js.map +1 -1
  10. package/dist/graphql.d.ts +11 -36
  11. package/dist/graphql.d.ts.map +1 -1
  12. package/dist/graphql.js +72 -311
  13. package/dist/graphql.js.map +1 -1
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -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 +27 -0
  27. package/dist/rest.d.ts.map +1 -0
  28. package/dist/rest.js +78 -0
  29. package/dist/rest.js.map +1 -0
  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 +23 -16
  34. package/dist/space.d.ts.map +1 -1
  35. package/dist/space.js +111 -78
  36. package/dist/space.js.map +1 -1
  37. package/dist/subscription.d.ts.map +1 -1
  38. package/dist/subscription.js +47 -40
  39. package/dist/subscription.js.map +1 -1
  40. package/dist/types.d.ts +85 -224
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js +0 -4
  43. package/dist/types.js.map +1 -1
  44. package/dist/webdav.d.ts +176 -0
  45. package/dist/webdav.d.ts.map +1 -0
  46. package/dist/webdav.js +495 -0
  47. package/dist/webdav.js.map +1 -0
  48. package/package.json +2 -1
  49. package/dist/apps.d.ts +0 -30
  50. package/dist/apps.d.ts.map +0 -1
  51. package/dist/apps.js +0 -81
  52. package/dist/apps.js.map +0 -1
  53. package/dist/media.d.ts +0 -76
  54. package/dist/media.d.ts.map +0 -1
  55. package/dist/media.js +0 -249
  56. package/dist/media.js.map +0 -1
package/dist/channel.js CHANGED
@@ -1,10 +1,13 @@
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 { 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
+ const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
6
+ const GET_OBJECTS_CHUNK_SIZE = 500;
4
7
  export function generateEntityId() {
5
8
  let result = '';
6
9
  for (let i = 0; i < 6; i++) {
7
- result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
10
+ result += ENTITY_CHARS[Math.floor(Math.random() * ENTITY_CHARS.length)];
8
11
  }
9
12
  return result;
10
13
  }
@@ -40,8 +43,118 @@ function findDefaultLeaf(interactions) {
40
43
  }
41
44
  return best?.id;
42
45
  }
43
- // Default timeout for waiting on SSE object events (30 seconds)
44
- const OBJECT_COLLECT_TIMEOUT = 30000;
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
+ }
85
+ function attachmentBody(file) {
86
+ if (isFile(file)) {
87
+ return {
88
+ filename: safeAttachmentFilename(file.name, file.type),
89
+ contentType: file.type || 'application/octet-stream',
90
+ body: file,
91
+ };
92
+ }
93
+ if (isBlob(file)) {
94
+ const contentType = file.type || 'application/octet-stream';
95
+ return {
96
+ filename: safeAttachmentFilename('attachment', contentType),
97
+ contentType,
98
+ body: file,
99
+ };
100
+ }
101
+ return {
102
+ filename: safeAttachmentFilename(file.filename ?? 'attachment', file.contentType),
103
+ contentType: file.contentType,
104
+ body: base64Body(file.data),
105
+ };
106
+ }
107
+ function isFile(value) {
108
+ return typeof File !== 'undefined' && value instanceof File;
109
+ }
110
+ function isBlob(value) {
111
+ return typeof Blob !== 'undefined' && value instanceof Blob;
112
+ }
113
+ function safeAttachmentFilename(name, contentType) {
114
+ const fallback = `attachment.${extensionForContentType(contentType)}`;
115
+ const leaf = name.split(/[/\\]/).pop() || fallback;
116
+ const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
117
+ return cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
118
+ }
119
+ function extensionForContentType(contentType) {
120
+ if (contentType === 'image/png')
121
+ return 'png';
122
+ if (contentType === 'image/jpeg')
123
+ return 'jpg';
124
+ if (contentType === 'image/gif')
125
+ return 'gif';
126
+ if (contentType === 'image/webp')
127
+ return 'webp';
128
+ if (contentType === 'image/svg+xml')
129
+ return 'svg';
130
+ if (contentType === 'application/pdf')
131
+ return 'pdf';
132
+ if (contentType === 'text/markdown')
133
+ return 'md';
134
+ if (contentType === 'text/plain')
135
+ return 'txt';
136
+ if (contentType === 'text/csv')
137
+ return 'csv';
138
+ if (contentType === 'text/html')
139
+ return 'html';
140
+ if (contentType === 'application/json')
141
+ return 'json';
142
+ if (contentType === 'application/xml')
143
+ return 'xml';
144
+ return 'bin';
145
+ }
146
+ function base64Body(data) {
147
+ const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
148
+ if (typeof Buffer !== 'undefined') {
149
+ const buffer = Buffer.from(clean, 'base64');
150
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
151
+ }
152
+ const binary = atob(clean);
153
+ const bytes = new Uint8Array(binary.length);
154
+ for (let i = 0; i < binary.length; i++)
155
+ bytes[i] = binary.charCodeAt(i);
156
+ return bytes.buffer;
157
+ }
45
158
  /**
46
159
  * A channel is a space + channelId pair.
47
160
  *
@@ -49,16 +162,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
49
162
  * at open time and cannot be changed. To use a different channel,
50
163
  * open a second one.
51
164
  *
52
- * Objects are fetched on demand from the server; only schema, metadata,
53
- * and the channel's own history are cached locally. Object changes
54
- * 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
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.
62
169
  */
63
170
  export class RoolChannel extends EventEmitter {
64
171
  _id;
@@ -70,24 +177,17 @@ export class RoolChannel extends EventEmitter {
70
177
  _conversationId;
71
178
  _closed = false;
72
179
  graphqlClient;
73
- mediaClient;
180
+ restClient;
181
+ webdav;
74
182
  onCloseCallback;
75
183
  logger;
76
- // Local cache for bounded data (schema, metadata, own channel, object IDs, stats)
184
+ // Local cache for bounded data (schema, metadata, own channel, object stats)
77
185
  _meta;
78
186
  _schema;
79
187
  _channel;
80
- _objectIds;
81
188
  _objectStats;
82
189
  // Active leaf per conversation (client-side tree cursor)
83
190
  _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)
86
- _pendingMutations = new Map();
87
- // Resolvers waiting for object data from SSE events
88
- _objectResolvers = new Map();
89
- // Buffer for object data that arrived before a collector was registered
90
- _objectBuffer = new Map();
91
191
  constructor(config) {
92
192
  super();
93
193
  this._id = config.id;
@@ -99,14 +199,14 @@ export class RoolChannel extends EventEmitter {
99
199
  this._channelId = config.channelId;
100
200
  this._conversationId = 'default';
101
201
  this.graphqlClient = config.graphqlClient;
102
- this.mediaClient = config.mediaClient;
202
+ this.restClient = config.restClient;
203
+ this.webdav = config.webdav;
103
204
  this.logger = config.logger;
104
205
  this.onCloseCallback = config.onClose;
105
206
  // Initialize local cache from server data
106
207
  this._meta = config.meta;
107
208
  this._schema = config.schema;
108
- this._channel = config.channel;
109
- this._objectIds = config.objectIds;
209
+ this._channel = config.channel ?? undefined;
110
210
  this._objectStats = new Map(Object.entries(config.objectStats));
111
211
  }
112
212
  /**
@@ -127,16 +227,12 @@ export class RoolChannel extends EventEmitter {
127
227
  return;
128
228
  this._meta = data.meta;
129
229
  this._schema = data.schema;
130
- this._objectIds = data.objectIds;
131
230
  this._objectStats = new Map(Object.entries(data.objectStats));
132
231
  if (data.channel)
133
232
  this._channel = data.channel;
134
233
  this._activeLeaves.clear();
135
234
  this.emit('reset', { source: 'system' });
136
235
  }
137
- // ===========================================================================
138
- // Properties
139
- // ===========================================================================
140
236
  get id() {
141
237
  return this._id;
142
238
  }
@@ -176,27 +272,6 @@ export class RoolChannel extends EventEmitter {
176
272
  get isReadOnly() {
177
273
  return this._role === 'viewer';
178
274
  }
179
- /**
180
- * Get the extension URL if this channel was created via installExtension, or null.
181
- */
182
- get extensionUrl() {
183
- return this._channel?.extensionUrl ?? null;
184
- }
185
- /**
186
- * Get the extension ID if this channel has an installed extension, or null.
187
- */
188
- get extensionId() {
189
- return this._channel?.extensionId ?? null;
190
- }
191
- /**
192
- * Get the extension manifest if this channel has an installed extension, or null.
193
- */
194
- get manifest() {
195
- return this._channel?.manifest ?? null;
196
- }
197
- // ===========================================================================
198
- // Channel History Access
199
- // ===========================================================================
200
275
  /**
201
276
  * Get the active branch of the current conversation as a flat array (root → leaf).
202
277
  * Walks from the active leaf up through parentId pointers.
@@ -298,22 +373,12 @@ export class RoolChannel extends EventEmitter {
298
373
  });
299
374
  }
300
375
  }
301
- // ===========================================================================
302
- // Conversations
303
- // ===========================================================================
304
376
  /**
305
377
  * Get a handle for a specific conversation within this channel.
306
- * The handle scopes AI and mutation operations to that conversation's
307
- * interaction history, while sharing the channel's single SSE connection.
308
- *
309
- * Conversations are auto-created on first interaction — no explicit create needed.
310
378
  */
311
379
  conversation(conversationId) {
312
380
  return new ConversationHandle(this, conversationId);
313
381
  }
314
- // ===========================================================================
315
- // Channel Lifecycle
316
- // ===========================================================================
317
382
  /**
318
383
  * Close this channel and clean up resources.
319
384
  * Stops real-time subscription and unregisters from client.
@@ -321,267 +386,225 @@ export class RoolChannel extends EventEmitter {
321
386
  close() {
322
387
  this._closed = true;
323
388
  this.onCloseCallback();
324
- // Clean up pending object collectors
325
- this._objectResolvers.clear();
326
- this._objectBuffer.clear();
327
- this._pendingMutations.clear();
328
389
  this.removeAllListeners();
329
390
  }
330
- // ===========================================================================
331
- // Undo / Redo (Server-managed checkpoints)
332
- // ===========================================================================
333
391
  /**
334
392
  * Create a checkpoint of the current space state.
335
- * Checkpoints are space-wide and shared across channels and users.
336
- * @returns The checkpoint ID
337
393
  */
338
394
  async checkpoint(label = 'Change') {
339
395
  const result = await this.graphqlClient.checkpoint(this._id, label, this._channelId);
340
396
  return result.checkpointId;
341
397
  }
342
- /**
343
- * Check if undo is available for this space.
344
- */
398
+ /** Check if undo is available for this space. */
345
399
  async canUndo() {
346
400
  const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
347
401
  return status.canUndo;
348
402
  }
349
- /**
350
- * Check if redo is available for this space.
351
- */
403
+ /** Check if redo is available for this space. */
352
404
  async canRedo() {
353
405
  const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
354
406
  return status.canRedo;
355
407
  }
356
- /**
357
- * Restore the space to the most recent checkpoint.
358
- * @returns true if undo was performed
359
- */
408
+ /** Restore the space to the most recent checkpoint. */
360
409
  async undo() {
361
410
  const result = await this.graphqlClient.undo(this._id, this._channelId);
362
411
  return result.success;
363
412
  }
364
- /**
365
- * Reapply the most recently undone checkpoint.
366
- * Affects the entire space.
367
- * @returns true if redo was performed
368
- */
413
+ /** Reapply the most recently undone checkpoint. */
369
414
  async redo() {
370
415
  const result = await this.graphqlClient.redo(this._id, this._channelId);
371
416
  return result.success;
372
417
  }
373
- /**
374
- * Clear the space's checkpoint history.
375
- */
418
+ /** Clear the space's checkpoint history. */
376
419
  async clearHistory() {
377
420
  await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
378
421
  }
379
- // ===========================================================================
380
- // Object Operations
381
- // ===========================================================================
382
- /**
383
- * Get an object's data by ID.
384
- * Fetches from the server on each call.
385
- */
386
- async getObject(objectId) {
387
- return this.graphqlClient.getObject(this._id, objectId);
388
- }
389
- /**
390
- * Get an object's stat (audit information).
391
- * Returns modification timestamp and author, or undefined if object not found.
392
- */
393
- stat(objectId) {
394
- return this._objectStats.get(objectId);
395
- }
396
- /**
397
- * Find objects using structured filters and/or natural language.
398
- *
399
- * `where` provides exact-match filtering — values must match literally (no placeholders or operators).
400
- * `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
401
- * constrain the data set before the AI sees it.
402
- *
403
- * @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
404
- * @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
405
- * @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
406
- * @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
407
- * @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
408
- * @param options.ephemeral - If true, the query won't be recorded in interaction history.
409
- * @returns The matching objects and a descriptive message.
410
- */
411
- async findObjects(options) {
412
- 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;
413
430
  }
414
- /** @internal */
415
- _findObjectsImpl(options, conversationId) {
416
- return this.graphqlClient.findObjects(this._id, options, 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
+ }
417
445
  }
418
- /**
419
- * Get all object IDs (sync, from local cache).
420
- * The list is loaded on open and kept current via SSE events.
421
- * @param options.limit - Maximum number of IDs to return
422
- * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
423
- */
424
- getObjectIds(options) {
425
- let ids = this._objectIds;
426
- if (options?.order === 'asc') {
427
- ids = [...ids].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);
428
460
  }
429
- if (options?.limit !== undefined) {
430
- ids = ids.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);
431
467
  }
432
- return ids;
468
+ return result;
433
469
  }
434
- /**
435
- * Create a new object with optional AI generation.
436
- * @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.
437
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
438
- * @returns The created object (with AI-filled content) and message
439
- */
440
- async createObject(options) {
441
- return this._createObjectImpl(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);
442
477
  }
443
478
  /** @internal */
444
- async _createObjectImpl(options, conversationId) {
445
- const { data, ephemeral } = options;
446
- // Use data.id if provided (string), otherwise generate
447
- const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
448
- // Validate ID format: alphanumeric, hyphens, underscores only
449
- if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
450
- throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
451
- }
452
- const dataWithId = { ...data, id: objectId };
453
- // Emit optimistic event and track for dedup
454
- this._pendingMutations.set(objectId, dataWithId);
455
- this.emit('objectCreated', { objectId, object: dataWithId, source: 'local_user' });
479
+ async _putObjectImpl(path, body, conversationId) {
480
+ const canonical = objectPath(path);
481
+ const optimistic = objectFromBody(canonical, body);
456
482
  try {
457
- // Await mutation — server processes AI placeholders before responding.
458
- // SSE events arrive during the await and are buffered via _deliverObject.
459
483
  const interactionId = generateEntityId();
460
- const { message } = await this.graphqlClient.createObject(this.id, dataWithId, this._channelId, conversationId, interactionId, ephemeral);
461
- // Collect resolved object from buffer (or wait if not yet arrived)
462
- const object = await this._collectObject(objectId);
463
- return { object, 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}` };
464
490
  }
465
491
  catch (error) {
466
- this.logger.error('[RoolChannel] Failed to create object:', error);
467
- this._pendingMutations.delete(objectId);
468
- this._cancelCollector(objectId);
469
- // Emit reset so UI can recover from the optimistic event
492
+ this.logger.error('[RoolChannel] Failed to put object:', error);
470
493
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
471
- this.emit('reset', { source: 'system' });
472
494
  throw error;
473
495
  }
474
496
  }
475
- /**
476
- * Update an existing object.
477
- * @param objectId - The ID of the object to update
478
- * @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.
479
- * @param options.prompt - AI prompt for content editing (optional).
480
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
481
- * @returns The updated object (with AI-filled content) and message
482
- */
483
- async updateObject(objectId, options) {
484
- return this._updateObjectImpl(objectId, 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);
485
500
  }
486
501
  /** @internal */
487
- async _updateObjectImpl(objectId, options, conversationId) {
488
- const { data, ephemeral } = options;
489
- // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
490
- if (data?.id !== undefined && data.id !== null) {
491
- throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
492
- }
493
- if (data && ('id' in data)) {
494
- throw new Error('Cannot delete id field. The id field is immutable after creation.');
495
- }
496
- // Normalize undefined to null (for JSON serialization) and build server data
497
- let serverData;
498
- if (data) {
499
- serverData = {};
500
- for (const [key, value] of Object.entries(data)) {
501
- // Convert undefined to null for wire protocol
502
- serverData[key] = value === undefined ? null : value;
503
- }
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);
510
+ try {
511
+ const interactionId = generateEntityId();
512
+ await this.webdav.put(canonical, JSON.stringify(body), {
513
+ contentType: 'application/json',
514
+ ifMatch: current.etag ?? undefined,
515
+ headers: this.davHeaders(conversationId, interactionId),
516
+ });
517
+ const fresh = await this.getObject(canonical) ?? optimistic;
518
+ return { object: fresh, message: `Patched ${canonical}` };
504
519
  }
505
- // Emit optimistic event if we have data changes
506
- if (data) {
507
- // Build optimistic object (best effort we may not have the current state)
508
- const optimistic = { id: objectId, ...data };
509
- this._pendingMutations.set(objectId, optimistic);
510
- this.emit('objectUpdated', { objectId, object: optimistic, source: 'local_user' });
520
+ catch (error) {
521
+ this.logger.error('[RoolChannel] Failed to patch object:', error);
522
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
523
+ throw error;
511
524
  }
525
+ }
526
+ /** Move an object JSON file to a new machine path, optionally replacing its body. */
527
+ async moveObject(from, to, options) {
528
+ return this._moveObjectImpl(from, to, options, this._conversationId);
529
+ }
530
+ /** @internal */
531
+ async _moveObjectImpl(from, to, options, conversationId) {
532
+ const fromPath = objectPath(from);
533
+ const toPath = objectPath(to);
534
+ const optimistic = objectFromBody(toPath, options?.body ?? {});
512
535
  try {
513
536
  const interactionId = generateEntityId();
514
- const { message } = await this.graphqlClient.updateObject(this.id, objectId, this._channelId, conversationId, interactionId, serverData, options.prompt, ephemeral);
515
- const object = await this._collectObject(objectId);
516
- return { object, message };
537
+ await this.webdav.move(fromPath, toPath, {
538
+ headers: this.davHeaders(conversationId, interactionId),
539
+ });
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}` };
517
549
  }
518
550
  catch (error) {
519
- this.logger.error('[RoolChannel] Failed to update object:', error);
520
- this._pendingMutations.delete(objectId);
521
- this._cancelCollector(objectId);
551
+ this.logger.error('[RoolChannel] Failed to move object:', error);
522
552
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
523
- this.emit('reset', { source: 'system' });
524
553
  throw error;
525
554
  }
526
555
  }
527
- /**
528
- * Delete objects by IDs.
529
- * Other objects that reference deleted objects via data fields will retain stale ref values.
530
- */
531
- async deleteObjects(objectIds) {
532
- return this._deleteObjectsImpl(objectIds, 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);
533
563
  }
534
564
  /** @internal */
535
- async _deleteObjectsImpl(objectIds, conversationId) {
536
- if (objectIds.length === 0)
565
+ async _deleteObjectsImpl(paths, conversationId) {
566
+ if (paths.length === 0)
537
567
  return;
538
- // Track for dedup and emit optimistic events
539
- for (const objectId of objectIds) {
540
- this._pendingMutations.set(objectId, null);
541
- this.emit('objectDeleted', { objectId, source: 'local_user' });
542
- }
568
+ const canonical = paths.map(objectPath);
543
569
  try {
544
- await this.graphqlClient.deleteObjects(this.id, objectIds, this._channelId, conversationId);
570
+ const interactionId = generateEntityId();
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
+ }
545
577
  }
546
578
  catch (error) {
547
- this.logger.error('[RoolChannel] Failed to delete objects:', error);
548
- for (const objectId of objectIds) {
549
- this._pendingMutations.delete(objectId);
550
- }
579
+ this.logger.error('[RoolChannel] Failed to delete paths:', error);
551
580
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
552
- this.emit('reset', { source: 'system' });
553
581
  throw error;
554
582
  }
555
583
  }
556
- // ===========================================================================
557
- // Collection Schema Operations
558
- // ===========================================================================
559
- /**
560
- * Get the current schema for this space.
561
- * Returns a map of collection names to their definitions.
562
- */
584
+ /** Get the current schema for this space. */
563
585
  getSchema() {
564
586
  return this._schema;
565
587
  }
566
- /**
567
- * Create a new collection schema.
568
- * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
569
- * @param fields - Field definitions for the collection
570
- * @returns The created CollectionDef
571
- */
572
- async createCollection(name, fields) {
573
- return this._createCollectionImpl(name, fields, this._conversationId);
588
+ /** Create a new collection schema. */
589
+ async createCollection(name, fields, options) {
590
+ return this._createCollectionImpl(name, fields, options, this._conversationId);
574
591
  }
575
592
  /** @internal */
576
- async _createCollectionImpl(name, fields, conversationId) {
593
+ async _createCollectionImpl(name, fields, options, conversationId) {
577
594
  if (this._schema[name]) {
578
595
  throw new Error(`Collection "${name}" already exists`);
579
596
  }
580
597
  // Optimistic local update
581
- const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
598
+ const optimisticDef = collectionDef(fields, options);
582
599
  this._schema[name] = optimisticDef;
583
600
  try {
584
- 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;
585
608
  }
586
609
  catch (error) {
587
610
  this.logger.error('[RoolChannel] Failed to create collection:', error);
@@ -589,25 +612,25 @@ export class RoolChannel extends EventEmitter {
589
612
  throw error;
590
613
  }
591
614
  }
592
- /**
593
- * Alter an existing collection schema, replacing its field definitions.
594
- * @param name - Name of the collection to alter
595
- * @param fields - New field definitions (replaces all existing fields)
596
- * @returns The updated CollectionDef
597
- */
598
- async alterCollection(name, fields) {
599
- return this._alterCollectionImpl(name, fields, this._conversationId);
615
+ /** Alter an existing collection schema, replacing its field definitions. */
616
+ async alterCollection(name, fields, options) {
617
+ return this._alterCollectionImpl(name, fields, options, this._conversationId);
600
618
  }
601
619
  /** @internal */
602
- async _alterCollectionImpl(name, fields, conversationId) {
620
+ async _alterCollectionImpl(name, fields, options, conversationId) {
603
621
  if (!this._schema[name]) {
604
622
  throw new Error(`Collection "${name}" not found`);
605
623
  }
606
624
  const previous = this._schema[name];
607
- // Optimistic local update
608
- this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
625
+ this._schema[name] = collectionDef(fields, options);
609
626
  try {
610
- 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;
611
634
  }
612
635
  catch (error) {
613
636
  this.logger.error('[RoolChannel] Failed to alter collection:', error);
@@ -615,10 +638,7 @@ export class RoolChannel extends EventEmitter {
615
638
  throw error;
616
639
  }
617
640
  }
618
- /**
619
- * Drop a collection schema.
620
- * @param name - Name of the collection to drop
621
- */
641
+ /** Drop a collection schema. */
622
642
  async dropCollection(name) {
623
643
  return this._dropCollectionImpl(name, this._conversationId);
624
644
  }
@@ -628,10 +648,10 @@ export class RoolChannel extends EventEmitter {
628
648
  throw new Error(`Collection "${name}" not found`);
629
649
  }
630
650
  const previous = this._schema[name];
631
- // Optimistic local update
632
651
  delete this._schema[name];
633
652
  try {
634
- 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' });
635
655
  }
636
656
  catch (error) {
637
657
  this.logger.error('[RoolChannel] Failed to drop collection:', error);
@@ -639,12 +659,8 @@ export class RoolChannel extends EventEmitter {
639
659
  throw error;
640
660
  }
641
661
  }
642
- // ===========================================================================
643
- // System Instructions
644
- // ===========================================================================
645
662
  /**
646
663
  * Get the system instruction for the current conversation.
647
- * Returns undefined if no system instruction is set.
648
664
  */
649
665
  getSystemInstruction() {
650
666
  return this._getSystemInstructionImpl(this._conversationId);
@@ -653,16 +669,12 @@ export class RoolChannel extends EventEmitter {
653
669
  _getSystemInstructionImpl(conversationId) {
654
670
  return this._channel?.conversations[conversationId]?.systemInstruction;
655
671
  }
656
- /**
657
- * Set the system instruction for the current conversation.
658
- * Pass null to clear the instruction.
659
- */
672
+ /** Set the system instruction for the current conversation. */
660
673
  async setSystemInstruction(instruction) {
661
674
  return this._setSystemInstructionImpl(instruction, this._conversationId);
662
675
  }
663
676
  /** @internal */
664
677
  async _setSystemInstructionImpl(instruction, conversationId) {
665
- // Optimistic local update
666
678
  this._ensureConversationImpl(conversationId);
667
679
  const conv = this._channel.conversations[conversationId];
668
680
  const previousInstruction = conv.systemInstruction;
@@ -672,7 +684,6 @@ export class RoolChannel extends EventEmitter {
672
684
  else {
673
685
  conv.systemInstruction = instruction;
674
686
  }
675
- // Emit events for backward compat and new API
676
687
  this.emit('conversationUpdated', {
677
688
  conversationId,
678
689
  channelId: this._channelId,
@@ -684,13 +695,11 @@ export class RoolChannel extends EventEmitter {
684
695
  source: 'local_user',
685
696
  });
686
697
  }
687
- // Call server
688
698
  try {
689
699
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
690
700
  }
691
701
  catch (error) {
692
702
  this.logger.error('[RoolChannel] Failed to set system instruction:', error);
693
- // Rollback
694
703
  if (previousInstruction === undefined) {
695
704
  delete conv.systemInstruction;
696
705
  }
@@ -700,15 +709,12 @@ export class RoolChannel extends EventEmitter {
700
709
  throw error;
701
710
  }
702
711
  }
703
- /**
704
- * Rename the current conversation.
705
- */
712
+ /** Rename the current conversation. */
706
713
  async renameConversation(name) {
707
714
  return this._renameConversationImpl(name, this._conversationId);
708
715
  }
709
716
  /** @internal */
710
717
  async _renameConversationImpl(name, conversationId) {
711
- // Optimistic local update
712
718
  this._ensureConversationImpl(conversationId);
713
719
  const conv = this._channel.conversations[conversationId];
714
720
  const previousName = conv.name;
@@ -724,21 +730,16 @@ export class RoolChannel extends EventEmitter {
724
730
  source: 'local_user',
725
731
  });
726
732
  }
727
- // Call server
728
733
  try {
729
734
  await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
730
735
  }
731
736
  catch (error) {
732
737
  this.logger.error('[RoolChannel] Failed to rename conversation:', error);
733
- // Rollback
734
738
  conv.name = previousName;
735
739
  throw error;
736
740
  }
737
741
  }
738
- /**
739
- * Ensure a conversation exists in the local channel cache.
740
- * @internal
741
- */
742
+ /** @internal */
742
743
  _ensureConversationImpl(conversationId) {
743
744
  if (!this._channel) {
744
745
  this._channel = {
@@ -755,13 +756,7 @@ export class RoolChannel extends EventEmitter {
755
756
  };
756
757
  }
757
758
  }
758
- // ===========================================================================
759
- // Metadata Operations
760
- // ===========================================================================
761
- /**
762
- * Set a space-level metadata value.
763
- * Metadata is stored in meta and hidden from AI operations.
764
- */
759
+ /** Set a space-level metadata value. */
765
760
  setMetadata(key, value) {
766
761
  this._setMetadataImpl(key, value, this._conversationId);
767
762
  }
@@ -770,212 +765,156 @@ export class RoolChannel extends EventEmitter {
770
765
  this._meta[key] = value;
771
766
  this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
772
767
  // Fire-and-forget server call
773
- this.graphqlClient.setSpaceMeta(this.id, this._meta, this._channelId, conversationId)
768
+ this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
774
769
  .catch((error) => {
775
770
  this.logger.error('[RoolChannel] Failed to set meta:', error);
776
771
  });
777
772
  }
778
- /**
779
- * Get a space-level metadata value.
780
- */
773
+ /** Get a space-level metadata value. */
781
774
  getMetadata(key) {
782
775
  return this._meta[key];
783
776
  }
784
- /**
785
- * Get all space-level metadata.
786
- */
777
+ /** Get all space-level metadata. */
787
778
  getAllMetadata() {
788
779
  return this._meta;
789
780
  }
790
- // ===========================================================================
791
- // AI Operations
792
- // ===========================================================================
793
781
  /**
794
782
  * Send a prompt to the AI agent for space manipulation.
795
- * @returns The message from the AI and the list of objects that were created or modified
783
+ * @returns The message from the AI and the list of objects that were created or modified.
796
784
  */
797
785
  async prompt(prompt, options) {
798
786
  return this._promptImpl(prompt, options, this._conversationId);
799
787
  }
800
788
  /** @internal */
801
789
  async _promptImpl(prompt, options, conversationId) {
802
- // Upload attachments via media endpoint, then send URLs to the server
803
- const { attachments, parentInteractionId: explicitParent, ...rest } = options ?? {};
804
- let attachmentUrls;
790
+ const { attachments, parentInteractionId: explicitParent, signal, ...rest } = options ?? {};
791
+ const interactionId = generateEntityId();
792
+ let attachmentRefs;
805
793
  if (attachments?.length) {
806
- attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
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
+ }));
807
798
  }
808
799
  // Auto-continue from active leaf if no explicit parent provided
809
800
  const parentInteractionId = explicitParent !== undefined
810
801
  ? explicitParent
811
802
  : (this._getActiveLeafImpl(conversationId) ?? null);
812
- const interactionId = generateEntityId();
813
803
  // Optimistically set active leaf before the server call.
814
804
  this._activeLeaves.set(conversationId, interactionId);
815
- const result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, { ...rest, attachmentUrls, interactionId, parentInteractionId });
816
- // Collect modified objects — they arrive via SSE events during/after the mutation.
817
- // Try collecting from buffer first, then fetch any missing from server.
818
- const objects = [];
819
- const missing = [];
820
- for (const id of result.modifiedObjectIds) {
821
- const buffered = this._objectBuffer.get(id);
822
- if (buffered) {
823
- this._objectBuffer.delete(id);
824
- objects.push(buffered);
805
+ let onAbort;
806
+ if (signal) {
807
+ if (signal.aborted) {
808
+ this.stopInteraction(interactionId).catch(() => { });
825
809
  }
826
810
  else {
827
- missing.push(id);
811
+ onAbort = () => {
812
+ this.stopInteraction(interactionId).catch(() => { });
813
+ };
814
+ signal.addEventListener('abort', onAbort, { once: true });
828
815
  }
829
816
  }
830
- // Fetch any objects not yet received via SSE
831
- if (missing.length > 0) {
832
- const fetched = await Promise.all(missing.map(id => this.graphqlClient.getObject(this._id, id)));
833
- for (const obj of fetched) {
834
- if (obj)
835
- objects.push(obj);
836
- }
817
+ let result;
818
+ try {
819
+ result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
820
+ ...rest,
821
+ attachmentRefs,
822
+ interactionId,
823
+ parentInteractionId,
824
+ });
825
+ }
826
+ finally {
827
+ if (onAbort)
828
+ signal.removeEventListener('abort', onAbort);
829
+ }
830
+ const objects = [];
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);
837
835
  }
838
836
  return {
839
837
  message: result.message,
840
838
  objects,
841
839
  };
842
840
  }
843
- // ===========================================================================
844
- // Channel Admin
845
- // ===========================================================================
846
841
  /**
847
- * Rename this channel.
842
+ * Stop the in-flight interaction on the default conversation, if any.
843
+ *
844
+ * No-op returning `false` when the active leaf is already finished or the
845
+ * conversation has no interactions. Stopping is best-effort: the server
846
+ * halts the agent loop and closes the stream, but an LLM turn already in
847
+ * flight keeps generating server-side and is billed.
848
848
  */
849
+ async stop() {
850
+ return this._stopImpl(this._conversationId);
851
+ }
852
+ /**
853
+ * Request that the server stop a specific in-flight interaction by ID.
854
+ *
855
+ * Returns whether the server stopped an interaction (`false` if it had
856
+ * already finished). Stopping is best-effort — see {@link stop}.
857
+ */
858
+ async stopInteraction(interactionId) {
859
+ return this.graphqlClient.stopInteraction(this._id, interactionId);
860
+ }
861
+ /** @internal */
862
+ async _stopImpl(conversationId) {
863
+ const leafId = this._getActiveLeafImpl(conversationId);
864
+ if (!leafId)
865
+ return false;
866
+ const interactions = this._channel?.conversations[conversationId]?.interactions;
867
+ const interaction = interactions && !Array.isArray(interactions) ? interactions[leafId] : undefined;
868
+ // Skip the round trip when we already know the interaction has settled.
869
+ if (interaction && (interaction.status === 'done' || interaction.status === 'error')) {
870
+ return false;
871
+ }
872
+ return this.stopInteraction(leafId);
873
+ }
874
+ /** Rename this channel. */
849
875
  async rename(newName) {
850
- // Optimistic local update
851
876
  const previousName = this._channel?.name;
852
877
  if (this._channel) {
853
878
  this._channel.name = newName;
854
879
  }
855
880
  this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
856
- // Call server
857
881
  try {
858
882
  await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
859
883
  }
860
884
  catch (error) {
861
885
  this.logger.error('[RoolChannel] Failed to rename channel:', error);
862
- // Rollback
863
886
  if (this._channel) {
864
887
  this._channel.name = previousName;
865
888
  }
866
889
  throw error;
867
890
  }
868
891
  }
869
- // ===========================================================================
870
- // Media Operations
871
- // ===========================================================================
872
- /**
873
- * List all media files for this space.
874
- */
875
- async listMedia() {
876
- return this.mediaClient.list(this._id);
877
- }
878
- /**
879
- * Upload a file to this space. Returns the URL.
880
- */
881
- async uploadMedia(file) {
882
- return this.mediaClient.upload(this._id, file);
883
- }
884
- /**
885
- * Fetch any URL, returning headers and a blob() method (like fetch Response).
886
- * Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
887
- * Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
888
- */
889
- async fetchMedia(url, options) {
890
- return this.mediaClient.fetch(this._id, url, options);
891
- }
892
- /**
893
- * Delete a media file by URL.
894
- */
895
- async deleteMedia(url) {
896
- return this.mediaClient.delete(this._id, url);
897
- }
898
- // ===========================================================================
899
- // Proxied Fetch
900
- // ===========================================================================
901
892
  /**
902
893
  * Fetch an external URL via the server proxy, bypassing CORS restrictions.
903
- * Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
904
- *
905
- * @param url - The URL to fetch
906
- * @param init - Optional method, headers, and body
907
- * @returns The proxied Response
908
894
  */
909
895
  async fetch(url, init) {
910
- return this.mediaClient.proxyFetch(this._id, url, init);
911
- }
912
- // ===========================================================================
913
- // Object Collection (internal)
914
- // ===========================================================================
915
- /**
916
- * Register a collector that resolves when the object arrives via SSE.
917
- * If the object is already in the buffer (arrived before collector), resolves immediately.
918
- * @internal
919
- */
920
- _collectObject(objectId) {
921
- return new Promise((resolve, reject) => {
922
- // Check buffer first SSE event may have arrived before the HTTP response
923
- const buffered = this._objectBuffer.get(objectId);
924
- if (buffered) {
925
- this._objectBuffer.delete(objectId);
926
- resolve(buffered);
927
- return;
928
- }
929
- const timer = setTimeout(() => {
930
- this._objectResolvers.delete(objectId);
931
- // Fallback: try to fetch from server
932
- this.graphqlClient.getObject(this._id, objectId).then(obj => {
933
- if (obj) {
934
- resolve(obj);
935
- }
936
- else {
937
- reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
938
- }
939
- }).catch(reject);
940
- }, OBJECT_COLLECT_TIMEOUT);
941
- this._objectResolvers.set(objectId, (obj) => {
942
- clearTimeout(timer);
943
- resolve(obj);
944
- });
945
- });
946
- }
947
- /**
948
- * Cancel a pending object collector (e.g., on mutation error).
949
- * @internal
950
- */
951
- _cancelCollector(objectId) {
952
- this._objectResolvers.delete(objectId);
953
- this._objectBuffer.delete(objectId);
954
- }
955
- /**
956
- * Deliver an object to a pending collector, or buffer it for later collection.
957
- * @internal
958
- */
959
- _deliverObject(objectId, object) {
960
- const resolver = this._objectResolvers.get(objectId);
961
- if (resolver) {
962
- resolver(object);
963
- this._objectResolvers.delete(objectId);
964
- }
965
- else {
966
- // Buffer for prompt() or late collectors
967
- this._objectBuffer.set(objectId, object);
968
- }
896
+ return this.restClient.proxyFetch(this._id, url, init);
897
+ }
898
+ async uploadAttachment(file, conversationId) {
899
+ await this.ensureCollection('/rool-drive/attachments');
900
+ const directory = `/rool-drive/attachments/${conversationId}`;
901
+ await this.ensureCollection(directory);
902
+ const attachment = attachmentBody(file);
903
+ const path = `${directory}/${attachment.filename}`;
904
+ await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
905
+ return path;
906
+ }
907
+ async ensureCollection(path) {
908
+ const response = await this.webdav.request('MKCOL', path, { collection: true });
909
+ if (response.status === 201 || response.status === 405)
910
+ return;
911
+ throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
969
912
  }
970
- // ===========================================================================
971
- // Event Handlers (internal - handles space subscription events)
972
- // ===========================================================================
973
913
  /**
974
914
  * Handle a channel event from the subscription.
975
915
  * @internal
976
916
  */
977
917
  handleChannelEvent(event) {
978
- // Ignore events after close - the channel is being torn down
979
918
  if (this._closed)
980
919
  return;
981
920
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
@@ -983,26 +922,6 @@ export class RoolChannel extends EventEmitter {
983
922
  case 'connected':
984
923
  // Resync is handled by the client via _applyResyncData.
985
924
  break;
986
- case 'object_created':
987
- if (event.objectId && event.object) {
988
- if (event.objectStat)
989
- this._objectStats.set(event.objectId, event.objectStat);
990
- this._handleObjectCreated(event.objectId, event.object, changeSource);
991
- }
992
- break;
993
- case 'object_updated':
994
- if (event.objectId && event.object) {
995
- if (event.objectStat)
996
- this._objectStats.set(event.objectId, event.objectStat);
997
- this._handleObjectUpdated(event.objectId, event.object, changeSource);
998
- }
999
- break;
1000
- case 'object_deleted':
1001
- if (event.objectId) {
1002
- this._objectStats.delete(event.objectId);
1003
- this._handleObjectDeleted(event.objectId, changeSource);
1004
- }
1005
- break;
1006
925
  case 'schema_updated':
1007
926
  if (event.schema) {
1008
927
  this._schema = event.schema;
@@ -1016,7 +935,6 @@ export class RoolChannel extends EventEmitter {
1016
935
  }
1017
936
  break;
1018
937
  case 'channel_updated':
1019
- // Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
1020
938
  if (event.channelId === this._channelId && event.channel) {
1021
939
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
1022
940
  this._channel = event.channel;
@@ -1025,8 +943,14 @@ export class RoolChannel extends EventEmitter {
1025
943
  }
1026
944
  }
1027
945
  break;
946
+ case 'channel_deleted':
947
+ if (event.channelId === this._channelId) {
948
+ this._channel = undefined;
949
+ this._activeLeaves.clear();
950
+ this.emit('reset', { source: changeSource });
951
+ }
952
+ break;
1028
953
  case 'conversation_updated':
1029
- // Only update if it's our channel
1030
954
  if (event.channelId === this._channelId && event.conversationId) {
1031
955
  if (!this._channel) {
1032
956
  this._channel = {
@@ -1037,17 +961,13 @@ export class RoolChannel extends EventEmitter {
1037
961
  }
1038
962
  const prev = this._channel.conversations[event.conversationId];
1039
963
  if (event.conversation) {
1040
- // Update or create conversation in local cache
1041
964
  this._channel.conversations[event.conversationId] = event.conversation;
1042
965
  }
1043
966
  else {
1044
- // Conversation was deleted
1045
967
  delete this._channel.conversations[event.conversationId];
1046
968
  }
1047
- // Skip emit if data is unchanged (e.g. echo of our own optimistic update)
1048
969
  if (JSON.stringify(prev) === JSON.stringify(event.conversation))
1049
970
  break;
1050
- // Auto-advance active leaf if someone continued our current branch
1051
971
  if (event.conversation && !Array.isArray(event.conversation.interactions)) {
1052
972
  const currentLeaf = this._getActiveLeafImpl(event.conversationId);
1053
973
  if (currentLeaf) {
@@ -1059,13 +979,11 @@ export class RoolChannel extends EventEmitter {
1059
979
  }
1060
980
  }
1061
981
  }
1062
- // Emit the new conversationUpdated event
1063
982
  this.emit('conversationUpdated', {
1064
983
  conversationId: event.conversationId,
1065
984
  channelId: event.channelId,
1066
985
  source: changeSource,
1067
986
  });
1068
- // Backward compat: also emit channelUpdated when the active conversation updates
1069
987
  if (event.conversationId === this._conversationId) {
1070
988
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1071
989
  }
@@ -1076,90 +994,9 @@ export class RoolChannel extends EventEmitter {
1076
994
  break;
1077
995
  }
1078
996
  }
1079
- /**
1080
- * Handle an object_created SSE event.
1081
- * Deduplicates against optimistic local creates.
1082
- * @internal
1083
- */
1084
- _handleObjectCreated(objectId, object, source) {
1085
- // Deliver to any pending collector (for mutation return values)
1086
- this._deliverObject(objectId, object);
1087
- // Maintain local ID list — prepend (most recently modified first)
1088
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1089
- const pending = this._pendingMutations.get(objectId);
1090
- if (pending !== undefined) {
1091
- // This is our own mutation echoed back
1092
- this._pendingMutations.delete(objectId);
1093
- if (pending !== null) {
1094
- // It was a create — already emitted objectCreated optimistically.
1095
- // Emit objectUpdated only if AI resolved placeholders (data changed).
1096
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1097
- this.emit('objectUpdated', { objectId, object, source });
1098
- }
1099
- }
1100
- }
1101
- else {
1102
- // Remote event — emit normally
1103
- this.emit('objectCreated', { objectId, object, source });
1104
- }
1105
- }
1106
- /**
1107
- * Handle an object_updated SSE event.
1108
- * Deduplicates against optimistic local updates.
1109
- * @internal
1110
- */
1111
- _handleObjectUpdated(objectId, object, source) {
1112
- // Deliver to any pending collector
1113
- this._deliverObject(objectId, object);
1114
- // Maintain local ID list — move to front (most recently modified)
1115
- this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
1116
- const pending = this._pendingMutations.get(objectId);
1117
- if (pending !== undefined) {
1118
- // This is our own mutation echoed back
1119
- this._pendingMutations.delete(objectId);
1120
- if (pending !== null) {
1121
- // Already emitted objectUpdated optimistically.
1122
- // Emit again only if data changed (AI resolved placeholders).
1123
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1124
- this.emit('objectUpdated', { objectId, object, source });
1125
- }
1126
- }
1127
- }
1128
- else {
1129
- // Remote event
1130
- this.emit('objectUpdated', { objectId, object, source });
1131
- }
1132
- }
1133
- /**
1134
- * Handle an object_deleted SSE event.
1135
- * Deduplicates against optimistic local deletes.
1136
- * @internal
1137
- */
1138
- _handleObjectDeleted(objectId, source) {
1139
- // Remove from local ID list
1140
- this._objectIds = this._objectIds.filter(id => id !== objectId);
1141
- const pending = this._pendingMutations.get(objectId);
1142
- if (pending !== undefined) {
1143
- // This is our own delete echoed back — already emitted
1144
- this._pendingMutations.delete(objectId);
1145
- }
1146
- else {
1147
- // Remote event
1148
- this.emit('objectDeleted', { objectId, source });
1149
- }
1150
- }
1151
997
  }
1152
- // =============================================================================
1153
- // ConversationHandle — Scoped proxy for a specific conversation
1154
- // =============================================================================
1155
998
  /**
1156
999
  * A lightweight handle for a specific conversation within a channel.
1157
- *
1158
- * Scopes AI and mutation operations to a particular conversation's interaction
1159
- * history, while sharing the channel's single SSE connection and object state.
1160
- *
1161
- * Obtain via `channel.conversation('thread-id')`.
1162
- * Conversations are auto-created on first interaction.
1163
1000
  */
1164
1001
  export class ConversationHandle {
1165
1002
  /** @internal */
@@ -1172,9 +1009,6 @@ export class ConversationHandle {
1172
1009
  }
1173
1010
  /** The conversation ID this handle is scoped to. */
1174
1011
  get conversationId() { return this._conversationId; }
1175
- // ---------------------------------------------------------------------------
1176
- // Conversation History
1177
- // ---------------------------------------------------------------------------
1178
1012
  /** Get the active branch of this conversation as a flat array (root → leaf). */
1179
1013
  getInteractions() {
1180
1014
  return this._channel._getInteractionsImpl(this._conversationId);
@@ -1203,51 +1037,50 @@ export class ConversationHandle {
1203
1037
  async rename(name) {
1204
1038
  return this._channel._renameConversationImpl(name, this._conversationId);
1205
1039
  }
1206
- // ---------------------------------------------------------------------------
1207
- // Object Operations (scoped to this conversation's interaction history)
1208
- // ---------------------------------------------------------------------------
1209
- /** Find objects using structured filters and/or natural language. */
1210
- async findObjects(options) {
1211
- return this._channel._findObjectsImpl(options, this._conversationId);
1040
+ /** Create or replace an object JSON file. */
1041
+ async putObject(path, body) {
1042
+ return this._channel._putObjectImpl(path, body, this._conversationId);
1043
+ }
1044
+ /** Patch an existing object JSON file. */
1045
+ async patchObject(path, options) {
1046
+ return this._channel._patchObjectImpl(path, options, this._conversationId);
1212
1047
  }
1213
- /** Create a new object. */
1214
- async createObject(options) {
1215
- return this._channel._createObjectImpl(options, this._conversationId);
1048
+ /** Move (rename/relocate) an object. */
1049
+ async moveObject(from, to, options) {
1050
+ return this._channel._moveObjectImpl(from, to, options, this._conversationId);
1216
1051
  }
1217
- /** Update an existing object. */
1218
- async updateObject(objectId, options) {
1219
- return this._channel._updateObjectImpl(objectId, options, this._conversationId);
1052
+ /** Delete object JSON files by path. */
1053
+ async deleteObjects(paths) {
1054
+ return this._channel._deleteObjectsImpl(paths, this._conversationId);
1220
1055
  }
1221
- /** Delete objects by IDs. */
1222
- async deleteObjects(objectIds) {
1223
- return this._channel._deleteObjectsImpl(objectIds, this._conversationId);
1056
+ /** @deprecated Use deleteObjects instead. */
1057
+ async deletePaths(paths) {
1058
+ return this.deleteObjects(paths);
1224
1059
  }
1225
- // ---------------------------------------------------------------------------
1226
- // AI
1227
- // ---------------------------------------------------------------------------
1228
1060
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1229
1061
  async prompt(text, options) {
1230
1062
  return this._channel._promptImpl(text, options, this._conversationId);
1231
1063
  }
1232
- // ---------------------------------------------------------------------------
1233
- // Schema (scoped to this conversation's interaction history)
1234
- // ---------------------------------------------------------------------------
1064
+ /**
1065
+ * Stop this conversation's in-flight interaction, if any. No-op returning
1066
+ * `false` when nothing is running. Stopping is best-effort — see
1067
+ * {@link RoolChannel.stop}.
1068
+ */
1069
+ async stop() {
1070
+ return this._channel._stopImpl(this._conversationId);
1071
+ }
1235
1072
  /** Create a new collection schema. */
1236
- async createCollection(name, fields) {
1237
- return this._channel._createCollectionImpl(name, fields, this._conversationId);
1073
+ async createCollection(name, fields, options) {
1074
+ return this._channel._createCollectionImpl(name, fields, options, this._conversationId);
1238
1075
  }
1239
1076
  /** Alter an existing collection schema. */
1240
- async alterCollection(name, fields) {
1241
- return this._channel._alterCollectionImpl(name, fields, this._conversationId);
1077
+ async alterCollection(name, fields, options) {
1078
+ return this._channel._alterCollectionImpl(name, fields, options, this._conversationId);
1242
1079
  }
1243
1080
  /** Drop a collection schema. */
1244
1081
  async dropCollection(name) {
1245
1082
  return this._channel._dropCollectionImpl(name, this._conversationId);
1246
1083
  }
1247
- // ---------------------------------------------------------------------------
1248
- // Metadata (scoped to this conversation's interaction history)
1249
- // ---------------------------------------------------------------------------
1250
- /** Set a space-level metadata value. */
1251
1084
  setMetadata(key, value) {
1252
1085
  return this._channel._setMetadataImpl(key, value, this._conversationId);
1253
1086
  }