@rool-dev/sdk 0.9.0 → 0.10.0-dev.2e3d7c6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -166
- package/dist/channel.d.ts +94 -184
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +360 -351
- package/dist/channel.js.map +1 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +59 -57
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +35 -19
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +114 -128
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/locations.d.ts +34 -0
- package/dist/locations.d.ts.map +1 -0
- package/dist/locations.js +90 -0
- package/dist/locations.js.map +1 -0
- package/dist/machine.d.ts +16 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +51 -0
- package/dist/machine.js.map +1 -0
- package/dist/rest.d.ts +18 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +46 -0
- package/dist/rest.js.map +1 -0
- package/dist/space.d.ts +20 -4
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +31 -45
- package/dist/space.js.map +1 -1
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +11 -12
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +79 -59
- package/dist/types.d.ts.map +1 -1
- package/dist/webdav.d.ts +153 -0
- package/dist/webdav.d.ts.map +1 -0
- package/dist/webdav.js +458 -0
- package/dist/webdav.js.map +1 -0
- package/package.json +1 -1
- package/dist/media.d.ts +0 -70
- package/dist/media.d.ts.map +0 -1
- package/dist/media.js +0 -228
- package/dist/media.js.map +0 -1
package/dist/channel.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from './event-emitter.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { generateBasename, loc, normalizeLocation, parseLocation } from './locations.js';
|
|
3
|
+
import { resolveMachineResource } from './machine.js';
|
|
4
|
+
// 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
|
|
5
|
+
const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
4
6
|
export function generateEntityId() {
|
|
5
7
|
let result = '';
|
|
6
8
|
for (let i = 0; i < 6; i++) {
|
|
7
|
-
result +=
|
|
9
|
+
result += ENTITY_CHARS[Math.floor(Math.random() * ENTITY_CHARS.length)];
|
|
8
10
|
}
|
|
9
11
|
return result;
|
|
10
12
|
}
|
|
@@ -40,6 +42,79 @@ function findDefaultLeaf(interactions) {
|
|
|
40
42
|
}
|
|
41
43
|
return best?.id;
|
|
42
44
|
}
|
|
45
|
+
function attachmentBody(file) {
|
|
46
|
+
if (isFile(file)) {
|
|
47
|
+
return {
|
|
48
|
+
filename: safeAttachmentFilename(file.name, file.type),
|
|
49
|
+
contentType: file.type || 'application/octet-stream',
|
|
50
|
+
body: file,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (isBlob(file)) {
|
|
54
|
+
const contentType = file.type || 'application/octet-stream';
|
|
55
|
+
return {
|
|
56
|
+
filename: safeAttachmentFilename('attachment', contentType),
|
|
57
|
+
contentType,
|
|
58
|
+
body: file,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
filename: safeAttachmentFilename('attachment', file.contentType),
|
|
63
|
+
contentType: file.contentType,
|
|
64
|
+
body: base64Body(file.data),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function isFile(value) {
|
|
68
|
+
return typeof File !== 'undefined' && value instanceof File;
|
|
69
|
+
}
|
|
70
|
+
function isBlob(value) {
|
|
71
|
+
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
72
|
+
}
|
|
73
|
+
function safeAttachmentFilename(name, contentType) {
|
|
74
|
+
const fallback = `attachment.${extensionForContentType(contentType)}`;
|
|
75
|
+
const leaf = name.split(/[/\\]/).pop() || fallback;
|
|
76
|
+
const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
|
|
77
|
+
return cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
|
|
78
|
+
}
|
|
79
|
+
function extensionForContentType(contentType) {
|
|
80
|
+
if (contentType === 'image/png')
|
|
81
|
+
return 'png';
|
|
82
|
+
if (contentType === 'image/jpeg')
|
|
83
|
+
return 'jpg';
|
|
84
|
+
if (contentType === 'image/gif')
|
|
85
|
+
return 'gif';
|
|
86
|
+
if (contentType === 'image/webp')
|
|
87
|
+
return 'webp';
|
|
88
|
+
if (contentType === 'image/svg+xml')
|
|
89
|
+
return 'svg';
|
|
90
|
+
if (contentType === 'application/pdf')
|
|
91
|
+
return 'pdf';
|
|
92
|
+
if (contentType === 'text/markdown')
|
|
93
|
+
return 'md';
|
|
94
|
+
if (contentType === 'text/plain')
|
|
95
|
+
return 'txt';
|
|
96
|
+
if (contentType === 'text/csv')
|
|
97
|
+
return 'csv';
|
|
98
|
+
if (contentType === 'text/html')
|
|
99
|
+
return 'html';
|
|
100
|
+
if (contentType === 'application/json')
|
|
101
|
+
return 'json';
|
|
102
|
+
if (contentType === 'application/xml')
|
|
103
|
+
return 'xml';
|
|
104
|
+
return 'bin';
|
|
105
|
+
}
|
|
106
|
+
function base64Body(data) {
|
|
107
|
+
const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
|
|
108
|
+
if (typeof Buffer !== 'undefined') {
|
|
109
|
+
const buffer = Buffer.from(clean, 'base64');
|
|
110
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
111
|
+
}
|
|
112
|
+
const binary = atob(clean);
|
|
113
|
+
const bytes = new Uint8Array(binary.length);
|
|
114
|
+
for (let i = 0; i < binary.length; i++)
|
|
115
|
+
bytes[i] = binary.charCodeAt(i);
|
|
116
|
+
return bytes.buffer;
|
|
117
|
+
}
|
|
43
118
|
// Default timeout for waiting on SSE object events (30 seconds)
|
|
44
119
|
const OBJECT_COLLECT_TIMEOUT = 30000;
|
|
45
120
|
/**
|
|
@@ -49,16 +124,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
|
|
|
49
124
|
* at open time and cannot be changed. To use a different channel,
|
|
50
125
|
* open a second one.
|
|
51
126
|
*
|
|
52
|
-
* Objects are
|
|
53
|
-
*
|
|
127
|
+
* Objects are addressed by location (`/space/<collection>/<basename>.json`).
|
|
128
|
+
* Only schema, metadata, the live object location list, and the channel's own
|
|
129
|
+
* history are cached locally. Object bodies are fetched on demand. Changes
|
|
54
130
|
* arrive via SSE semantic events and are emitted as SDK events.
|
|
55
|
-
*
|
|
56
|
-
* Features:
|
|
57
|
-
* - High-level object operations
|
|
58
|
-
* - Built-in undo/redo with checkpoints
|
|
59
|
-
* - Metadata management
|
|
60
|
-
* - Event emission for state changes
|
|
61
|
-
* - Real-time updates via space-specific subscription
|
|
62
131
|
*/
|
|
63
132
|
export class RoolChannel extends EventEmitter {
|
|
64
133
|
_id;
|
|
@@ -70,23 +139,24 @@ export class RoolChannel extends EventEmitter {
|
|
|
70
139
|
_conversationId;
|
|
71
140
|
_closed = false;
|
|
72
141
|
graphqlClient;
|
|
73
|
-
|
|
142
|
+
restClient;
|
|
143
|
+
webdav;
|
|
74
144
|
onCloseCallback;
|
|
75
145
|
logger;
|
|
76
|
-
// Local cache for bounded data (schema, metadata, own channel, object
|
|
146
|
+
// Local cache for bounded data (schema, metadata, own channel, object locations, stats)
|
|
77
147
|
_meta;
|
|
78
148
|
_schema;
|
|
79
149
|
_channel;
|
|
80
|
-
|
|
150
|
+
_objectLocations;
|
|
81
151
|
_objectStats;
|
|
82
152
|
// Active leaf per conversation (client-side tree cursor)
|
|
83
153
|
_activeLeaves = new Map();
|
|
84
|
-
// Object collection: tracks pending local mutations for dedup
|
|
85
|
-
// Maps
|
|
154
|
+
// Object collection: tracks pending local mutations (by location) for dedup
|
|
155
|
+
// Maps location → optimistic object (for create/update) or null (for delete)
|
|
86
156
|
_pendingMutations = new Map();
|
|
87
|
-
// Resolvers waiting for object data from SSE events
|
|
157
|
+
// Resolvers waiting for object data from SSE events, keyed by location
|
|
88
158
|
_objectResolvers = new Map();
|
|
89
|
-
// Buffer for object data that arrived before a collector was registered
|
|
159
|
+
// Buffer for object data that arrived before a collector was registered, keyed by location
|
|
90
160
|
_objectBuffer = new Map();
|
|
91
161
|
constructor(config) {
|
|
92
162
|
super();
|
|
@@ -99,14 +169,15 @@ export class RoolChannel extends EventEmitter {
|
|
|
99
169
|
this._channelId = config.channelId;
|
|
100
170
|
this._conversationId = 'default';
|
|
101
171
|
this.graphqlClient = config.graphqlClient;
|
|
102
|
-
this.
|
|
172
|
+
this.restClient = config.restClient;
|
|
173
|
+
this.webdav = config.webdav;
|
|
103
174
|
this.logger = config.logger;
|
|
104
175
|
this.onCloseCallback = config.onClose;
|
|
105
176
|
// Initialize local cache from server data
|
|
106
177
|
this._meta = config.meta;
|
|
107
178
|
this._schema = config.schema;
|
|
108
179
|
this._channel = config.channel;
|
|
109
|
-
this.
|
|
180
|
+
this._objectLocations = config.objectLocations;
|
|
110
181
|
this._objectStats = new Map(Object.entries(config.objectStats));
|
|
111
182
|
}
|
|
112
183
|
/**
|
|
@@ -127,7 +198,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
127
198
|
return;
|
|
128
199
|
this._meta = data.meta;
|
|
129
200
|
this._schema = data.schema;
|
|
130
|
-
this.
|
|
201
|
+
this._objectLocations = data.objectLocations;
|
|
131
202
|
this._objectStats = new Map(Object.entries(data.objectStats));
|
|
132
203
|
if (data.channel)
|
|
133
204
|
this._channel = data.channel;
|
|
@@ -294,10 +365,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
294
365
|
}
|
|
295
366
|
/**
|
|
296
367
|
* Get a handle for a specific conversation within this channel.
|
|
297
|
-
* The handle scopes AI and mutation operations to that conversation's
|
|
298
|
-
* interaction history, while sharing the channel's single SSE connection.
|
|
299
|
-
*
|
|
300
|
-
* Conversations are auto-created on first interaction — no explicit create needed.
|
|
301
368
|
*/
|
|
302
369
|
conversation(conversationId) {
|
|
303
370
|
return new ConversationHandle(this, conversationId);
|
|
@@ -317,138 +384,109 @@ export class RoolChannel extends EventEmitter {
|
|
|
317
384
|
}
|
|
318
385
|
/**
|
|
319
386
|
* Create a checkpoint of the current space state.
|
|
320
|
-
* Checkpoints are space-wide and shared across channels and users.
|
|
321
|
-
* @returns The checkpoint ID
|
|
322
387
|
*/
|
|
323
388
|
async checkpoint(label = 'Change') {
|
|
324
389
|
const result = await this.graphqlClient.checkpoint(this._id, label, this._channelId);
|
|
325
390
|
return result.checkpointId;
|
|
326
391
|
}
|
|
327
|
-
/**
|
|
328
|
-
* Check if undo is available for this space.
|
|
329
|
-
*/
|
|
392
|
+
/** Check if undo is available for this space. */
|
|
330
393
|
async canUndo() {
|
|
331
394
|
const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
|
|
332
395
|
return status.canUndo;
|
|
333
396
|
}
|
|
334
|
-
/**
|
|
335
|
-
* Check if redo is available for this space.
|
|
336
|
-
*/
|
|
397
|
+
/** Check if redo is available for this space. */
|
|
337
398
|
async canRedo() {
|
|
338
399
|
const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
|
|
339
400
|
return status.canRedo;
|
|
340
401
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Restore the space to the most recent checkpoint.
|
|
343
|
-
* @returns true if undo was performed
|
|
344
|
-
*/
|
|
402
|
+
/** Restore the space to the most recent checkpoint. */
|
|
345
403
|
async undo() {
|
|
346
404
|
const result = await this.graphqlClient.undo(this._id, this._channelId);
|
|
347
405
|
return result.success;
|
|
348
406
|
}
|
|
349
|
-
/**
|
|
350
|
-
* Reapply the most recently undone checkpoint.
|
|
351
|
-
* Affects the entire space.
|
|
352
|
-
* @returns true if redo was performed
|
|
353
|
-
*/
|
|
407
|
+
/** Reapply the most recently undone checkpoint. */
|
|
354
408
|
async redo() {
|
|
355
409
|
const result = await this.graphqlClient.redo(this._id, this._channelId);
|
|
356
410
|
return result.success;
|
|
357
411
|
}
|
|
358
|
-
/**
|
|
359
|
-
* Clear the space's checkpoint history.
|
|
360
|
-
*/
|
|
412
|
+
/** Clear the space's checkpoint history. */
|
|
361
413
|
async clearHistory() {
|
|
362
414
|
await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
|
|
363
415
|
}
|
|
364
416
|
/**
|
|
365
|
-
* Get an object
|
|
366
|
-
*
|
|
417
|
+
* Get an object by location. Fetches from the server on each call.
|
|
418
|
+
*
|
|
419
|
+
* Accepts either the canonical form (`/space/<collection>/<basename>.json`)
|
|
420
|
+
* or the short form (`<collection>/<basename>`).
|
|
367
421
|
*/
|
|
368
|
-
async getObject(
|
|
369
|
-
return this.graphqlClient.getObject(this._id,
|
|
422
|
+
async getObject(location) {
|
|
423
|
+
return this.graphqlClient.getObject(this._id, normalizeLocation(location));
|
|
370
424
|
}
|
|
371
425
|
/**
|
|
372
426
|
* Get an object's stat (audit information).
|
|
373
|
-
* Returns
|
|
427
|
+
* Returns the cached stat or undefined if not known.
|
|
374
428
|
*/
|
|
375
|
-
stat(
|
|
376
|
-
return this._objectStats.get(
|
|
429
|
+
stat(location) {
|
|
430
|
+
return this._objectStats.get(normalizeLocation(location));
|
|
377
431
|
}
|
|
378
432
|
/**
|
|
379
433
|
* Find objects using structured filters and/or natural language.
|
|
380
|
-
*
|
|
381
|
-
* `where` provides exact-match filtering — values must match literally (no placeholders or operators).
|
|
382
|
-
* `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
|
|
383
|
-
* constrain the data set before the AI sees it.
|
|
384
|
-
*
|
|
385
|
-
* @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
|
|
386
|
-
* @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
|
|
387
|
-
* @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
|
|
388
|
-
* @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
|
|
389
|
-
* @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
|
|
390
|
-
* @param options.ephemeral - If true, the query won't be recorded in interaction history.
|
|
391
|
-
* @returns The matching objects and a descriptive message.
|
|
392
434
|
*/
|
|
393
435
|
async findObjects(options) {
|
|
394
436
|
return this._findObjectsImpl(options, this._conversationId);
|
|
395
437
|
}
|
|
396
438
|
/** @internal */
|
|
397
439
|
_findObjectsImpl(options, conversationId) {
|
|
398
|
-
|
|
440
|
+
const normalized = {
|
|
441
|
+
...options,
|
|
442
|
+
locations: options.locations?.map(normalizeLocation),
|
|
443
|
+
};
|
|
444
|
+
return this.graphqlClient.findObjects(this._id, normalized, this._channelId, conversationId);
|
|
399
445
|
}
|
|
400
446
|
/**
|
|
401
|
-
* Get all object
|
|
447
|
+
* Get all object locations (sync, from local cache).
|
|
402
448
|
* The list is loaded on open and kept current via SSE events.
|
|
403
|
-
* @param options.limit - Maximum number of IDs to return
|
|
404
|
-
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
405
449
|
*/
|
|
406
|
-
|
|
407
|
-
let
|
|
450
|
+
getObjectLocations(options) {
|
|
451
|
+
let locs = this._objectLocations;
|
|
408
452
|
if (options?.order === 'asc') {
|
|
409
|
-
|
|
453
|
+
locs = [...locs].reverse();
|
|
410
454
|
}
|
|
411
455
|
if (options?.limit !== undefined) {
|
|
412
|
-
|
|
456
|
+
locs = locs.slice(0, options.limit);
|
|
413
457
|
}
|
|
414
|
-
return
|
|
458
|
+
return locs;
|
|
415
459
|
}
|
|
416
460
|
/**
|
|
417
|
-
* Create a new object
|
|
418
|
-
*
|
|
461
|
+
* Create a new object in the given collection.
|
|
462
|
+
*
|
|
463
|
+
* @param collection - The collection (must exist in the schema)
|
|
464
|
+
* @param body - Object body fields. Use `{{placeholder}}` for AI-generated content.
|
|
465
|
+
* Fields prefixed with `_` are hidden from AI.
|
|
466
|
+
* @param options.basename - Specific basename to use. If omitted, the SDK generates a random one.
|
|
419
467
|
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
420
|
-
* @returns The created object
|
|
468
|
+
* @returns The created object and a status message.
|
|
421
469
|
*/
|
|
422
|
-
async createObject(options) {
|
|
423
|
-
return this._createObjectImpl(options, this._conversationId);
|
|
470
|
+
async createObject(collection, body, options) {
|
|
471
|
+
return this._createObjectImpl(collection, body, options, this._conversationId);
|
|
424
472
|
}
|
|
425
473
|
/** @internal */
|
|
426
|
-
async _createObjectImpl(options, conversationId) {
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
|
|
433
|
-
}
|
|
434
|
-
const dataWithId = { ...data, id: objectId };
|
|
435
|
-
// Emit optimistic event and track for dedup
|
|
436
|
-
this._pendingMutations.set(objectId, dataWithId);
|
|
437
|
-
this.emit('objectCreated', { objectId, object: dataWithId, source: 'local_user' });
|
|
474
|
+
async _createObjectImpl(collection, body, options, conversationId) {
|
|
475
|
+
const basename = options?.basename ?? generateBasename();
|
|
476
|
+
const location = loc(collection, basename);
|
|
477
|
+
const optimistic = { location, collection, basename, body };
|
|
478
|
+
this._pendingMutations.set(location, optimistic);
|
|
479
|
+
this.emit('objectCreated', { location, object: optimistic, source: 'local_user' });
|
|
438
480
|
try {
|
|
439
|
-
// Await mutation — server processes AI placeholders before responding.
|
|
440
|
-
// SSE events arrive during the await and are buffered via _deliverObject.
|
|
441
481
|
const interactionId = generateEntityId();
|
|
442
|
-
const { message } = await this.graphqlClient.createObject(this.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return { object, message };
|
|
482
|
+
const { message, object } = await this.graphqlClient.createObject(this._id, location, body, this._channelId, conversationId, interactionId, { ephemeral: options?.ephemeral, parentInteractionId: options?.parentInteractionId });
|
|
483
|
+
const fresh = object ?? await this._collectObject(location);
|
|
484
|
+
return { object: fresh, message };
|
|
446
485
|
}
|
|
447
486
|
catch (error) {
|
|
448
487
|
this.logger.error('[RoolChannel] Failed to create object:', error);
|
|
449
|
-
this._pendingMutations.delete(
|
|
450
|
-
this._cancelCollector(
|
|
451
|
-
// Emit reset so UI can recover from the optimistic event
|
|
488
|
+
this._pendingMutations.delete(location);
|
|
489
|
+
this._cancelCollector(location);
|
|
452
490
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
453
491
|
this.emit('reset', { source: 'system' });
|
|
454
492
|
throw error;
|
|
@@ -456,98 +494,135 @@ export class RoolChannel extends EventEmitter {
|
|
|
456
494
|
}
|
|
457
495
|
/**
|
|
458
496
|
* Update an existing object.
|
|
459
|
-
*
|
|
460
|
-
* @param
|
|
461
|
-
* @param options.
|
|
497
|
+
*
|
|
498
|
+
* @param location - The object's location (canonical or short form)
|
|
499
|
+
* @param options.data - Fields to add or update. Pass `null` to delete a field. Use `{{placeholder}}` for AI-generated content.
|
|
500
|
+
* @param options.prompt - AI prompt to drive the update.
|
|
462
501
|
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
463
|
-
* @returns The updated object (with AI-filled content) and message
|
|
464
502
|
*/
|
|
465
|
-
async updateObject(
|
|
466
|
-
return this._updateObjectImpl(
|
|
503
|
+
async updateObject(location, options) {
|
|
504
|
+
return this._updateObjectImpl(location, options, this._conversationId);
|
|
467
505
|
}
|
|
468
506
|
/** @internal */
|
|
469
|
-
async _updateObjectImpl(
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
if (data && ('id' in data)) {
|
|
476
|
-
throw new Error('Cannot delete id field. The id field is immutable after creation.');
|
|
477
|
-
}
|
|
478
|
-
// Normalize undefined to null (for JSON serialization) and build server data
|
|
479
|
-
let serverData;
|
|
507
|
+
async _updateObjectImpl(location, options, conversationId) {
|
|
508
|
+
const canonical = normalizeLocation(location);
|
|
509
|
+
const { data } = options;
|
|
510
|
+
// Normalize undefined to null (for JSON serialization) and build server patch
|
|
511
|
+
let serverPatch;
|
|
480
512
|
if (data) {
|
|
481
|
-
|
|
513
|
+
serverPatch = {};
|
|
482
514
|
for (const [key, value] of Object.entries(data)) {
|
|
483
|
-
|
|
484
|
-
serverData[key] = value === undefined ? null : value;
|
|
515
|
+
serverPatch[key] = value === undefined ? null : value;
|
|
485
516
|
}
|
|
486
517
|
}
|
|
487
518
|
// Emit optimistic event if we have data changes
|
|
488
519
|
if (data) {
|
|
489
|
-
|
|
490
|
-
const optimistic = {
|
|
491
|
-
this._pendingMutations.set(
|
|
492
|
-
this.emit('objectUpdated', {
|
|
520
|
+
const { collection, basename } = parseLocation(canonical);
|
|
521
|
+
const optimistic = { location: canonical, collection, basename, body: data };
|
|
522
|
+
this._pendingMutations.set(canonical, optimistic);
|
|
523
|
+
this.emit('objectUpdated', { location: canonical, object: optimistic, source: 'local_user' });
|
|
493
524
|
}
|
|
494
525
|
try {
|
|
495
526
|
const interactionId = generateEntityId();
|
|
496
|
-
const { message } = await this.graphqlClient.updateObject(this.
|
|
497
|
-
|
|
498
|
-
|
|
527
|
+
const { message, object } = await this.graphqlClient.updateObject(this._id, canonical, this._channelId, conversationId, interactionId, {
|
|
528
|
+
patch: serverPatch,
|
|
529
|
+
prompt: options.prompt,
|
|
530
|
+
ephemeral: options.ephemeral,
|
|
531
|
+
parentInteractionId: options.parentInteractionId,
|
|
532
|
+
});
|
|
533
|
+
const fresh = object ?? await this._collectObject(canonical);
|
|
534
|
+
return { object: fresh, message };
|
|
499
535
|
}
|
|
500
536
|
catch (error) {
|
|
501
537
|
this.logger.error('[RoolChannel] Failed to update object:', error);
|
|
502
|
-
this._pendingMutations.delete(
|
|
503
|
-
this._cancelCollector(
|
|
538
|
+
this._pendingMutations.delete(canonical);
|
|
539
|
+
this._cancelCollector(canonical);
|
|
504
540
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
505
541
|
this.emit('reset', { source: 'system' });
|
|
506
542
|
throw error;
|
|
507
543
|
}
|
|
508
544
|
}
|
|
509
545
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
546
|
+
* Move (rename or relocate) an object to a new location.
|
|
547
|
+
* Use this to rename, change collection, or atomically rewrite the body.
|
|
548
|
+
*
|
|
549
|
+
* @param from - Current location
|
|
550
|
+
* @param to - New location
|
|
551
|
+
* @param options.body - Replace the body atomically as part of the move.
|
|
552
|
+
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
553
|
+
*/
|
|
554
|
+
async moveObject(from, to, options) {
|
|
555
|
+
return this._moveObjectImpl(from, to, options, this._conversationId);
|
|
556
|
+
}
|
|
557
|
+
/** @internal */
|
|
558
|
+
async _moveObjectImpl(from, to, options, conversationId) {
|
|
559
|
+
const fromLoc = normalizeLocation(from);
|
|
560
|
+
const toLoc = normalizeLocation(to);
|
|
561
|
+
// Optimistic event — emit move so listeners can update keys
|
|
562
|
+
const { collection, basename } = parseLocation(toLoc);
|
|
563
|
+
const optimistic = {
|
|
564
|
+
location: toLoc,
|
|
565
|
+
collection,
|
|
566
|
+
basename,
|
|
567
|
+
body: options?.body ?? {},
|
|
568
|
+
};
|
|
569
|
+
this._pendingMutations.set(toLoc, optimistic);
|
|
570
|
+
this.emit('objectMoved', { from: fromLoc, to: toLoc, object: optimistic, source: 'local_user' });
|
|
571
|
+
try {
|
|
572
|
+
const interactionId = generateEntityId();
|
|
573
|
+
const { message, object } = await this.graphqlClient.moveObject(this._id, fromLoc, toLoc, this._channelId, conversationId, interactionId, {
|
|
574
|
+
body: options?.body,
|
|
575
|
+
ephemeral: options?.ephemeral,
|
|
576
|
+
parentInteractionId: options?.parentInteractionId,
|
|
577
|
+
});
|
|
578
|
+
const fresh = object ?? await this._collectObject(toLoc);
|
|
579
|
+
return { object: fresh, message };
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
this.logger.error('[RoolChannel] Failed to move object:', error);
|
|
583
|
+
this._pendingMutations.delete(toLoc);
|
|
584
|
+
this._cancelCollector(toLoc);
|
|
585
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
586
|
+
this.emit('reset', { source: 'system' });
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Delete objects by location.
|
|
592
|
+
* Other objects that reference deleted objects will retain stale ref values.
|
|
512
593
|
*/
|
|
513
|
-
async deleteObjects(
|
|
514
|
-
return this._deleteObjectsImpl(
|
|
594
|
+
async deleteObjects(locations) {
|
|
595
|
+
return this._deleteObjectsImpl(locations, this._conversationId);
|
|
515
596
|
}
|
|
516
597
|
/** @internal */
|
|
517
|
-
async _deleteObjectsImpl(
|
|
518
|
-
if (
|
|
598
|
+
async _deleteObjectsImpl(locations, conversationId) {
|
|
599
|
+
if (locations.length === 0)
|
|
519
600
|
return;
|
|
601
|
+
const canonical = locations.map(normalizeLocation);
|
|
520
602
|
// Track for dedup and emit optimistic events
|
|
521
|
-
for (const
|
|
522
|
-
this._pendingMutations.set(
|
|
523
|
-
this.emit('objectDeleted', {
|
|
603
|
+
for (const location of canonical) {
|
|
604
|
+
this._pendingMutations.set(location, null);
|
|
605
|
+
this.emit('objectDeleted', { location, source: 'local_user' });
|
|
524
606
|
}
|
|
525
607
|
try {
|
|
526
|
-
|
|
608
|
+
const interactionId = generateEntityId();
|
|
609
|
+
await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
|
|
527
610
|
}
|
|
528
611
|
catch (error) {
|
|
529
612
|
this.logger.error('[RoolChannel] Failed to delete objects:', error);
|
|
530
|
-
for (const
|
|
531
|
-
this._pendingMutations.delete(
|
|
613
|
+
for (const location of canonical) {
|
|
614
|
+
this._pendingMutations.delete(location);
|
|
532
615
|
}
|
|
533
616
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
534
617
|
this.emit('reset', { source: 'system' });
|
|
535
618
|
throw error;
|
|
536
619
|
}
|
|
537
620
|
}
|
|
538
|
-
/**
|
|
539
|
-
* Get the current schema for this space.
|
|
540
|
-
* Returns a map of collection names to their definitions.
|
|
541
|
-
*/
|
|
621
|
+
/** Get the current schema for this space. */
|
|
542
622
|
getSchema() {
|
|
543
623
|
return this._schema;
|
|
544
624
|
}
|
|
545
|
-
/**
|
|
546
|
-
* Create a new collection schema.
|
|
547
|
-
* @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
|
|
548
|
-
* @param fields - Field definitions for the collection
|
|
549
|
-
* @returns The created CollectionDef
|
|
550
|
-
*/
|
|
625
|
+
/** Create a new collection schema. */
|
|
551
626
|
async createCollection(name, fields) {
|
|
552
627
|
return this._createCollectionImpl(name, fields, this._conversationId);
|
|
553
628
|
}
|
|
@@ -568,12 +643,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
568
643
|
throw error;
|
|
569
644
|
}
|
|
570
645
|
}
|
|
571
|
-
/**
|
|
572
|
-
* Alter an existing collection schema, replacing its field definitions.
|
|
573
|
-
* @param name - Name of the collection to alter
|
|
574
|
-
* @param fields - New field definitions (replaces all existing fields)
|
|
575
|
-
* @returns The updated CollectionDef
|
|
576
|
-
*/
|
|
646
|
+
/** Alter an existing collection schema, replacing its field definitions. */
|
|
577
647
|
async alterCollection(name, fields) {
|
|
578
648
|
return this._alterCollectionImpl(name, fields, this._conversationId);
|
|
579
649
|
}
|
|
@@ -583,7 +653,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
583
653
|
throw new Error(`Collection "${name}" not found`);
|
|
584
654
|
}
|
|
585
655
|
const previous = this._schema[name];
|
|
586
|
-
// Optimistic local update
|
|
587
656
|
this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
|
|
588
657
|
try {
|
|
589
658
|
return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
|
|
@@ -594,10 +663,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
594
663
|
throw error;
|
|
595
664
|
}
|
|
596
665
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Drop a collection schema.
|
|
599
|
-
* @param name - Name of the collection to drop
|
|
600
|
-
*/
|
|
666
|
+
/** Drop a collection schema. */
|
|
601
667
|
async dropCollection(name) {
|
|
602
668
|
return this._dropCollectionImpl(name, this._conversationId);
|
|
603
669
|
}
|
|
@@ -607,7 +673,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
607
673
|
throw new Error(`Collection "${name}" not found`);
|
|
608
674
|
}
|
|
609
675
|
const previous = this._schema[name];
|
|
610
|
-
// Optimistic local update
|
|
611
676
|
delete this._schema[name];
|
|
612
677
|
try {
|
|
613
678
|
await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
|
|
@@ -620,7 +685,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
620
685
|
}
|
|
621
686
|
/**
|
|
622
687
|
* Get the system instruction for the current conversation.
|
|
623
|
-
* Returns undefined if no system instruction is set.
|
|
624
688
|
*/
|
|
625
689
|
getSystemInstruction() {
|
|
626
690
|
return this._getSystemInstructionImpl(this._conversationId);
|
|
@@ -629,16 +693,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
629
693
|
_getSystemInstructionImpl(conversationId) {
|
|
630
694
|
return this._channel?.conversations[conversationId]?.systemInstruction;
|
|
631
695
|
}
|
|
632
|
-
/**
|
|
633
|
-
* Set the system instruction for the current conversation.
|
|
634
|
-
* Pass null to clear the instruction.
|
|
635
|
-
*/
|
|
696
|
+
/** Set the system instruction for the current conversation. */
|
|
636
697
|
async setSystemInstruction(instruction) {
|
|
637
698
|
return this._setSystemInstructionImpl(instruction, this._conversationId);
|
|
638
699
|
}
|
|
639
700
|
/** @internal */
|
|
640
701
|
async _setSystemInstructionImpl(instruction, conversationId) {
|
|
641
|
-
// Optimistic local update
|
|
642
702
|
this._ensureConversationImpl(conversationId);
|
|
643
703
|
const conv = this._channel.conversations[conversationId];
|
|
644
704
|
const previousInstruction = conv.systemInstruction;
|
|
@@ -648,7 +708,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
648
708
|
else {
|
|
649
709
|
conv.systemInstruction = instruction;
|
|
650
710
|
}
|
|
651
|
-
// Emit events for backward compat and new API
|
|
652
711
|
this.emit('conversationUpdated', {
|
|
653
712
|
conversationId,
|
|
654
713
|
channelId: this._channelId,
|
|
@@ -660,13 +719,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
660
719
|
source: 'local_user',
|
|
661
720
|
});
|
|
662
721
|
}
|
|
663
|
-
// Call server
|
|
664
722
|
try {
|
|
665
723
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
|
|
666
724
|
}
|
|
667
725
|
catch (error) {
|
|
668
726
|
this.logger.error('[RoolChannel] Failed to set system instruction:', error);
|
|
669
|
-
// Rollback
|
|
670
727
|
if (previousInstruction === undefined) {
|
|
671
728
|
delete conv.systemInstruction;
|
|
672
729
|
}
|
|
@@ -676,15 +733,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
676
733
|
throw error;
|
|
677
734
|
}
|
|
678
735
|
}
|
|
679
|
-
/**
|
|
680
|
-
* Rename the current conversation.
|
|
681
|
-
*/
|
|
736
|
+
/** Rename the current conversation. */
|
|
682
737
|
async renameConversation(name) {
|
|
683
738
|
return this._renameConversationImpl(name, this._conversationId);
|
|
684
739
|
}
|
|
685
740
|
/** @internal */
|
|
686
741
|
async _renameConversationImpl(name, conversationId) {
|
|
687
|
-
// Optimistic local update
|
|
688
742
|
this._ensureConversationImpl(conversationId);
|
|
689
743
|
const conv = this._channel.conversations[conversationId];
|
|
690
744
|
const previousName = conv.name;
|
|
@@ -700,21 +754,16 @@ export class RoolChannel extends EventEmitter {
|
|
|
700
754
|
source: 'local_user',
|
|
701
755
|
});
|
|
702
756
|
}
|
|
703
|
-
// Call server
|
|
704
757
|
try {
|
|
705
758
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
|
|
706
759
|
}
|
|
707
760
|
catch (error) {
|
|
708
761
|
this.logger.error('[RoolChannel] Failed to rename conversation:', error);
|
|
709
|
-
// Rollback
|
|
710
762
|
conv.name = previousName;
|
|
711
763
|
throw error;
|
|
712
764
|
}
|
|
713
765
|
}
|
|
714
|
-
/**
|
|
715
|
-
* Ensure a conversation exists in the local channel cache.
|
|
716
|
-
* @internal
|
|
717
|
-
*/
|
|
766
|
+
/** @internal */
|
|
718
767
|
_ensureConversationImpl(conversationId) {
|
|
719
768
|
if (!this._channel) {
|
|
720
769
|
this._channel = {
|
|
@@ -731,10 +780,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
731
780
|
};
|
|
732
781
|
}
|
|
733
782
|
}
|
|
734
|
-
/**
|
|
735
|
-
* Set a space-level metadata value.
|
|
736
|
-
* Metadata is stored in meta and hidden from AI operations.
|
|
737
|
-
*/
|
|
783
|
+
/** Set a space-level metadata value. */
|
|
738
784
|
setMetadata(key, value) {
|
|
739
785
|
this._setMetadataImpl(key, value, this._conversationId);
|
|
740
786
|
}
|
|
@@ -743,50 +789,44 @@ export class RoolChannel extends EventEmitter {
|
|
|
743
789
|
this._meta[key] = value;
|
|
744
790
|
this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
|
|
745
791
|
// Fire-and-forget server call
|
|
746
|
-
this.graphqlClient.setSpaceMeta(this.
|
|
792
|
+
this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
|
|
747
793
|
.catch((error) => {
|
|
748
794
|
this.logger.error('[RoolChannel] Failed to set meta:', error);
|
|
749
795
|
});
|
|
750
796
|
}
|
|
751
|
-
/**
|
|
752
|
-
* Get a space-level metadata value.
|
|
753
|
-
*/
|
|
797
|
+
/** Get a space-level metadata value. */
|
|
754
798
|
getMetadata(key) {
|
|
755
799
|
return this._meta[key];
|
|
756
800
|
}
|
|
757
|
-
/**
|
|
758
|
-
* Get all space-level metadata.
|
|
759
|
-
*/
|
|
801
|
+
/** Get all space-level metadata. */
|
|
760
802
|
getAllMetadata() {
|
|
761
803
|
return this._meta;
|
|
762
804
|
}
|
|
763
805
|
/**
|
|
764
806
|
* Send a prompt to the AI agent for space manipulation.
|
|
765
|
-
* @returns The message from the AI and the list of objects that were created or modified
|
|
807
|
+
* @returns The message from the AI and the list of objects that were created or modified.
|
|
766
808
|
*/
|
|
767
809
|
async prompt(prompt, options) {
|
|
768
810
|
return this._promptImpl(prompt, options, this._conversationId);
|
|
769
811
|
}
|
|
770
812
|
/** @internal */
|
|
771
813
|
async _promptImpl(prompt, options, conversationId) {
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
let
|
|
814
|
+
const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
|
|
815
|
+
const interactionId = generateEntityId();
|
|
816
|
+
let attachmentRefs;
|
|
775
817
|
if (attachments?.length) {
|
|
776
|
-
|
|
818
|
+
const resources = await Promise.all(attachments.map((file) => this.uploadAttachment(file, conversationId)));
|
|
819
|
+
attachmentRefs = resources.map((resource) => `rool-machine:${resource.path.split('/').map(encodeURIComponent).join('/')}`);
|
|
777
820
|
}
|
|
778
821
|
// Auto-continue from active leaf if no explicit parent provided
|
|
779
822
|
const parentInteractionId = explicitParent !== undefined
|
|
780
823
|
? explicitParent
|
|
781
824
|
: (this._getActiveLeafImpl(conversationId) ?? null);
|
|
782
|
-
const interactionId = generateEntityId();
|
|
783
825
|
// Optimistically set active leaf before the server call.
|
|
784
826
|
this._activeLeaves.set(conversationId, interactionId);
|
|
785
827
|
let onAbort;
|
|
786
828
|
if (signal) {
|
|
787
829
|
if (signal.aborted) {
|
|
788
|
-
// Caller aborted before we even started; fire-and-forget the stop so
|
|
789
|
-
// the server-side prompt (about to start) is cancelled too.
|
|
790
830
|
this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
|
|
791
831
|
}
|
|
792
832
|
else {
|
|
@@ -798,29 +838,33 @@ export class RoolChannel extends EventEmitter {
|
|
|
798
838
|
}
|
|
799
839
|
let result;
|
|
800
840
|
try {
|
|
801
|
-
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
841
|
+
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
842
|
+
...rest,
|
|
843
|
+
locations: locations?.map(normalizeLocation),
|
|
844
|
+
attachmentRefs,
|
|
845
|
+
interactionId,
|
|
846
|
+
parentInteractionId,
|
|
847
|
+
});
|
|
802
848
|
}
|
|
803
849
|
finally {
|
|
804
850
|
if (onAbort)
|
|
805
851
|
signal.removeEventListener('abort', onAbort);
|
|
806
852
|
}
|
|
807
853
|
// Collect modified objects — they arrive via SSE events during/after the mutation.
|
|
808
|
-
// Try collecting from buffer first, then fetch any missing from server.
|
|
809
854
|
const objects = [];
|
|
810
855
|
const missing = [];
|
|
811
|
-
for (const
|
|
812
|
-
const buffered = this._objectBuffer.get(
|
|
856
|
+
for (const location of result.modifiedObjectLocations) {
|
|
857
|
+
const buffered = this._objectBuffer.get(location);
|
|
813
858
|
if (buffered) {
|
|
814
|
-
this._objectBuffer.delete(
|
|
859
|
+
this._objectBuffer.delete(location);
|
|
815
860
|
objects.push(buffered);
|
|
816
861
|
}
|
|
817
862
|
else {
|
|
818
|
-
missing.push(
|
|
863
|
+
missing.push(location);
|
|
819
864
|
}
|
|
820
865
|
}
|
|
821
|
-
// Fetch any objects not yet received via SSE
|
|
822
866
|
if (missing.length > 0) {
|
|
823
|
-
const fetched = await Promise.all(missing.map(
|
|
867
|
+
const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
|
|
824
868
|
for (const obj of fetched) {
|
|
825
869
|
if (obj)
|
|
826
870
|
objects.push(obj);
|
|
@@ -831,119 +875,93 @@ export class RoolChannel extends EventEmitter {
|
|
|
831
875
|
objects,
|
|
832
876
|
};
|
|
833
877
|
}
|
|
834
|
-
/**
|
|
835
|
-
* Rename this channel.
|
|
836
|
-
*/
|
|
878
|
+
/** Rename this channel. */
|
|
837
879
|
async rename(newName) {
|
|
838
|
-
// Optimistic local update
|
|
839
880
|
const previousName = this._channel?.name;
|
|
840
881
|
if (this._channel) {
|
|
841
882
|
this._channel.name = newName;
|
|
842
883
|
}
|
|
843
884
|
this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
|
|
844
|
-
// Call server
|
|
845
885
|
try {
|
|
846
886
|
await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
|
|
847
887
|
}
|
|
848
888
|
catch (error) {
|
|
849
889
|
this.logger.error('[RoolChannel] Failed to rename channel:', error);
|
|
850
|
-
// Rollback
|
|
851
890
|
if (this._channel) {
|
|
852
891
|
this._channel.name = previousName;
|
|
853
892
|
}
|
|
854
893
|
throw error;
|
|
855
894
|
}
|
|
856
895
|
}
|
|
857
|
-
/**
|
|
858
|
-
* List all media files for this space.
|
|
859
|
-
*/
|
|
860
|
-
async listMedia() {
|
|
861
|
-
return this.mediaClient.list(this._id);
|
|
862
|
-
}
|
|
863
|
-
/**
|
|
864
|
-
* Upload a file to this space. Returns the URL.
|
|
865
|
-
*/
|
|
866
|
-
async uploadMedia(file) {
|
|
867
|
-
return this.mediaClient.upload(this._id, file);
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
871
|
-
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
872
|
-
* Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
|
|
873
|
-
*/
|
|
874
|
-
async fetchMedia(url, options) {
|
|
875
|
-
return this.mediaClient.fetch(this._id, url, options);
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Delete a media file by URL.
|
|
879
|
-
*/
|
|
880
|
-
async deleteMedia(url) {
|
|
881
|
-
return this.mediaClient.delete(this._id, url);
|
|
882
|
-
}
|
|
883
896
|
/**
|
|
884
897
|
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
885
|
-
* Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
|
|
886
|
-
*
|
|
887
|
-
* @param url - The URL to fetch
|
|
888
|
-
* @param init - Optional method, headers, and body
|
|
889
|
-
* @returns The proxied Response
|
|
890
898
|
*/
|
|
891
899
|
async fetch(url, init) {
|
|
892
|
-
return this.
|
|
900
|
+
return this.restClient.proxyFetch(this._id, url, init);
|
|
901
|
+
}
|
|
902
|
+
async uploadAttachment(file, conversationId) {
|
|
903
|
+
await this.ensureCollection('attachments');
|
|
904
|
+
const directory = `attachments/${conversationId}`;
|
|
905
|
+
await this.ensureCollection(directory);
|
|
906
|
+
const attachment = attachmentBody(file);
|
|
907
|
+
const path = `${directory}/${attachment.filename}`;
|
|
908
|
+
await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
|
|
909
|
+
const resource = resolveMachineResource(`/rool-drive/${path}`);
|
|
910
|
+
if (!resource)
|
|
911
|
+
throw new Error('Failed to resolve uploaded attachment');
|
|
912
|
+
return resource;
|
|
913
|
+
}
|
|
914
|
+
async ensureCollection(path) {
|
|
915
|
+
// Note: not an object collection, a folder, which is "collection" in webdav land
|
|
916
|
+
const response = await this.webdav.request('MKCOL', path, { collection: true });
|
|
917
|
+
if (response.status === 201 || response.status === 405)
|
|
918
|
+
return;
|
|
919
|
+
throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
|
|
893
920
|
}
|
|
894
921
|
/**
|
|
895
922
|
* Register a collector that resolves when the object arrives via SSE.
|
|
896
|
-
* If the object is already in the buffer (arrived before collector), resolves immediately.
|
|
897
923
|
* @internal
|
|
898
924
|
*/
|
|
899
|
-
_collectObject(
|
|
925
|
+
_collectObject(location) {
|
|
900
926
|
return new Promise((resolve, reject) => {
|
|
901
|
-
|
|
902
|
-
const buffered = this._objectBuffer.get(objectId);
|
|
927
|
+
const buffered = this._objectBuffer.get(location);
|
|
903
928
|
if (buffered) {
|
|
904
|
-
this._objectBuffer.delete(
|
|
929
|
+
this._objectBuffer.delete(location);
|
|
905
930
|
resolve(buffered);
|
|
906
931
|
return;
|
|
907
932
|
}
|
|
908
933
|
const timer = setTimeout(() => {
|
|
909
|
-
this._objectResolvers.delete(
|
|
934
|
+
this._objectResolvers.delete(location);
|
|
910
935
|
// Fallback: try to fetch from server
|
|
911
|
-
this.graphqlClient.getObject(this._id,
|
|
936
|
+
this.graphqlClient.getObject(this._id, location).then(obj => {
|
|
912
937
|
if (obj) {
|
|
913
938
|
resolve(obj);
|
|
914
939
|
}
|
|
915
940
|
else {
|
|
916
|
-
reject(new Error(`Timeout waiting for object ${
|
|
941
|
+
reject(new Error(`Timeout waiting for object ${location} from SSE`));
|
|
917
942
|
}
|
|
918
943
|
}).catch(reject);
|
|
919
944
|
}, OBJECT_COLLECT_TIMEOUT);
|
|
920
|
-
this._objectResolvers.set(
|
|
945
|
+
this._objectResolvers.set(location, (obj) => {
|
|
921
946
|
clearTimeout(timer);
|
|
922
947
|
resolve(obj);
|
|
923
948
|
});
|
|
924
949
|
});
|
|
925
950
|
}
|
|
926
|
-
/**
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
_cancelCollector(objectId) {
|
|
931
|
-
this._objectResolvers.delete(objectId);
|
|
932
|
-
this._objectBuffer.delete(objectId);
|
|
951
|
+
/** @internal */
|
|
952
|
+
_cancelCollector(location) {
|
|
953
|
+
this._objectResolvers.delete(location);
|
|
954
|
+
this._objectBuffer.delete(location);
|
|
933
955
|
}
|
|
934
|
-
/**
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
*/
|
|
938
|
-
_deliverObject(objectId, object) {
|
|
939
|
-
const resolver = this._objectResolvers.get(objectId);
|
|
956
|
+
/** @internal */
|
|
957
|
+
_deliverObject(location, object) {
|
|
958
|
+
const resolver = this._objectResolvers.get(location);
|
|
940
959
|
if (resolver) {
|
|
941
960
|
resolver(object);
|
|
942
|
-
this._objectResolvers.delete(
|
|
961
|
+
this._objectResolvers.delete(location);
|
|
943
962
|
}
|
|
944
963
|
else {
|
|
945
|
-
|
|
946
|
-
this._objectBuffer.set(objectId, object);
|
|
964
|
+
this._objectBuffer.set(location, object);
|
|
947
965
|
}
|
|
948
966
|
}
|
|
949
967
|
/**
|
|
@@ -951,7 +969,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
951
969
|
* @internal
|
|
952
970
|
*/
|
|
953
971
|
handleChannelEvent(event) {
|
|
954
|
-
// Ignore events after close - the channel is being torn down
|
|
955
972
|
if (this._closed)
|
|
956
973
|
return;
|
|
957
974
|
const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
@@ -960,23 +977,31 @@ export class RoolChannel extends EventEmitter {
|
|
|
960
977
|
// Resync is handled by the client via _applyResyncData.
|
|
961
978
|
break;
|
|
962
979
|
case 'object_created':
|
|
963
|
-
if (event.
|
|
980
|
+
if (event.location && event.object) {
|
|
964
981
|
if (event.objectStat)
|
|
965
|
-
this._objectStats.set(event.
|
|
966
|
-
this._handleObjectCreated(event.
|
|
982
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
983
|
+
this._handleObjectCreated(event.location, event.object, changeSource);
|
|
967
984
|
}
|
|
968
985
|
break;
|
|
969
986
|
case 'object_updated':
|
|
970
|
-
if (event.
|
|
987
|
+
if (event.location && event.object) {
|
|
971
988
|
if (event.objectStat)
|
|
972
|
-
this._objectStats.set(event.
|
|
973
|
-
this._handleObjectUpdated(event.
|
|
989
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
990
|
+
this._handleObjectUpdated(event.location, event.object, changeSource);
|
|
974
991
|
}
|
|
975
992
|
break;
|
|
976
993
|
case 'object_deleted':
|
|
977
|
-
if (event.
|
|
978
|
-
this._objectStats.delete(event.
|
|
979
|
-
this._handleObjectDeleted(event.
|
|
994
|
+
if (event.location) {
|
|
995
|
+
this._objectStats.delete(event.location);
|
|
996
|
+
this._handleObjectDeleted(event.location, changeSource);
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
case 'object_moved':
|
|
1000
|
+
if (event.from && event.to && event.object) {
|
|
1001
|
+
this._objectStats.delete(event.from);
|
|
1002
|
+
if (event.objectStat)
|
|
1003
|
+
this._objectStats.set(event.to, event.objectStat);
|
|
1004
|
+
this._handleObjectMoved(event.from, event.to, event.object, changeSource);
|
|
980
1005
|
}
|
|
981
1006
|
break;
|
|
982
1007
|
case 'schema_updated':
|
|
@@ -992,7 +1017,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
992
1017
|
}
|
|
993
1018
|
break;
|
|
994
1019
|
case 'channel_updated':
|
|
995
|
-
// Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
|
|
996
1020
|
if (event.channelId === this._channelId && event.channel) {
|
|
997
1021
|
const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
|
|
998
1022
|
this._channel = event.channel;
|
|
@@ -1002,7 +1026,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
1002
1026
|
}
|
|
1003
1027
|
break;
|
|
1004
1028
|
case 'conversation_updated':
|
|
1005
|
-
// Only update if it's our channel
|
|
1006
1029
|
if (event.channelId === this._channelId && event.conversationId) {
|
|
1007
1030
|
if (!this._channel) {
|
|
1008
1031
|
this._channel = {
|
|
@@ -1013,17 +1036,13 @@ export class RoolChannel extends EventEmitter {
|
|
|
1013
1036
|
}
|
|
1014
1037
|
const prev = this._channel.conversations[event.conversationId];
|
|
1015
1038
|
if (event.conversation) {
|
|
1016
|
-
// Update or create conversation in local cache
|
|
1017
1039
|
this._channel.conversations[event.conversationId] = event.conversation;
|
|
1018
1040
|
}
|
|
1019
1041
|
else {
|
|
1020
|
-
// Conversation was deleted
|
|
1021
1042
|
delete this._channel.conversations[event.conversationId];
|
|
1022
1043
|
}
|
|
1023
|
-
// Skip emit if data is unchanged (e.g. echo of our own optimistic update)
|
|
1024
1044
|
if (JSON.stringify(prev) === JSON.stringify(event.conversation))
|
|
1025
1045
|
break;
|
|
1026
|
-
// Auto-advance active leaf if someone continued our current branch
|
|
1027
1046
|
if (event.conversation && !Array.isArray(event.conversation.interactions)) {
|
|
1028
1047
|
const currentLeaf = this._getActiveLeafImpl(event.conversationId);
|
|
1029
1048
|
if (currentLeaf) {
|
|
@@ -1035,13 +1054,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
1035
1054
|
}
|
|
1036
1055
|
}
|
|
1037
1056
|
}
|
|
1038
|
-
// Emit the new conversationUpdated event
|
|
1039
1057
|
this.emit('conversationUpdated', {
|
|
1040
1058
|
conversationId: event.conversationId,
|
|
1041
1059
|
channelId: event.channelId,
|
|
1042
1060
|
source: changeSource,
|
|
1043
1061
|
});
|
|
1044
|
-
// Backward compat: also emit channelUpdated when the active conversation updates
|
|
1045
1062
|
if (event.conversationId === this._conversationId) {
|
|
1046
1063
|
this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
|
|
1047
1064
|
}
|
|
@@ -1052,87 +1069,75 @@ export class RoolChannel extends EventEmitter {
|
|
|
1052
1069
|
break;
|
|
1053
1070
|
}
|
|
1054
1071
|
}
|
|
1055
|
-
/**
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
// Deliver to any pending collector (for mutation return values)
|
|
1062
|
-
this._deliverObject(objectId, object);
|
|
1063
|
-
// Maintain local ID list — prepend (most recently modified first)
|
|
1064
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1065
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1072
|
+
/** @internal */
|
|
1073
|
+
_handleObjectCreated(location, object, source) {
|
|
1074
|
+
this._deliverObject(location, object);
|
|
1075
|
+
// Maintain local location list — prepend (most recently modified first)
|
|
1076
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1077
|
+
const pending = this._pendingMutations.get(location);
|
|
1066
1078
|
if (pending !== undefined) {
|
|
1067
|
-
|
|
1068
|
-
this._pendingMutations.delete(objectId);
|
|
1079
|
+
this._pendingMutations.delete(location);
|
|
1069
1080
|
if (pending !== null) {
|
|
1070
|
-
//
|
|
1081
|
+
// Already emitted objectCreated optimistically.
|
|
1071
1082
|
// Emit objectUpdated only if AI resolved placeholders (data changed).
|
|
1072
1083
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1073
|
-
this.emit('objectUpdated', {
|
|
1084
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1074
1085
|
}
|
|
1075
1086
|
}
|
|
1076
1087
|
}
|
|
1077
1088
|
else {
|
|
1078
|
-
|
|
1079
|
-
this.emit('objectCreated', { objectId, object, source });
|
|
1089
|
+
this.emit('objectCreated', { location, object, source });
|
|
1080
1090
|
}
|
|
1081
1091
|
}
|
|
1082
|
-
/**
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
_handleObjectUpdated(objectId, object, source) {
|
|
1088
|
-
// Deliver to any pending collector
|
|
1089
|
-
this._deliverObject(objectId, object);
|
|
1090
|
-
// Maintain local ID list — move to front (most recently modified)
|
|
1091
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1092
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1092
|
+
/** @internal */
|
|
1093
|
+
_handleObjectUpdated(location, object, source) {
|
|
1094
|
+
this._deliverObject(location, object);
|
|
1095
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1096
|
+
const pending = this._pendingMutations.get(location);
|
|
1093
1097
|
if (pending !== undefined) {
|
|
1094
|
-
|
|
1095
|
-
this._pendingMutations.delete(objectId);
|
|
1098
|
+
this._pendingMutations.delete(location);
|
|
1096
1099
|
if (pending !== null) {
|
|
1097
|
-
// Already emitted objectUpdated optimistically.
|
|
1098
|
-
// Emit again only if data changed (AI resolved placeholders).
|
|
1099
1100
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1100
|
-
this.emit('objectUpdated', {
|
|
1101
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1101
1102
|
}
|
|
1102
1103
|
}
|
|
1103
1104
|
}
|
|
1104
1105
|
else {
|
|
1105
|
-
|
|
1106
|
-
this.emit('objectUpdated', { objectId, object, source });
|
|
1106
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1107
1107
|
}
|
|
1108
1108
|
}
|
|
1109
|
-
/**
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
*/
|
|
1114
|
-
_handleObjectDeleted(objectId, source) {
|
|
1115
|
-
// Remove from local ID list
|
|
1116
|
-
this._objectIds = this._objectIds.filter(id => id !== objectId);
|
|
1117
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1109
|
+
/** @internal */
|
|
1110
|
+
_handleObjectDeleted(location, source) {
|
|
1111
|
+
this._objectLocations = this._objectLocations.filter(l => l !== location);
|
|
1112
|
+
const pending = this._pendingMutations.get(location);
|
|
1118
1113
|
if (pending !== undefined) {
|
|
1119
|
-
|
|
1120
|
-
this._pendingMutations.delete(objectId);
|
|
1114
|
+
this._pendingMutations.delete(location);
|
|
1121
1115
|
}
|
|
1122
1116
|
else {
|
|
1123
|
-
|
|
1124
|
-
|
|
1117
|
+
this.emit('objectDeleted', { location, source });
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/** @internal */
|
|
1121
|
+
_handleObjectMoved(from, to, object, source) {
|
|
1122
|
+
this._deliverObject(to, object);
|
|
1123
|
+
// Drop old location, insert new one at the front.
|
|
1124
|
+
this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
|
|
1125
|
+
const pending = this._pendingMutations.get(to);
|
|
1126
|
+
if (pending !== undefined) {
|
|
1127
|
+
this._pendingMutations.delete(to);
|
|
1128
|
+
if (pending !== null) {
|
|
1129
|
+
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1130
|
+
this.emit('objectUpdated', { location: to, object, source });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
this.emit('objectMoved', { from, to, object, source });
|
|
1125
1136
|
}
|
|
1126
1137
|
}
|
|
1127
1138
|
}
|
|
1128
1139
|
/**
|
|
1129
1140
|
* A lightweight handle for a specific conversation within a channel.
|
|
1130
|
-
*
|
|
1131
|
-
* Scopes AI and mutation operations to a particular conversation's interaction
|
|
1132
|
-
* history, while sharing the channel's single SSE connection and object state.
|
|
1133
|
-
*
|
|
1134
|
-
* Obtain via `channel.conversation('thread-id')`.
|
|
1135
|
-
* Conversations are auto-created on first interaction.
|
|
1136
1141
|
*/
|
|
1137
1142
|
export class ConversationHandle {
|
|
1138
1143
|
/** @internal */
|
|
@@ -1178,16 +1183,20 @@ export class ConversationHandle {
|
|
|
1178
1183
|
return this._channel._findObjectsImpl(options, this._conversationId);
|
|
1179
1184
|
}
|
|
1180
1185
|
/** Create a new object. */
|
|
1181
|
-
async createObject(options) {
|
|
1182
|
-
return this._channel._createObjectImpl(options, this._conversationId);
|
|
1186
|
+
async createObject(collection, body, options) {
|
|
1187
|
+
return this._channel._createObjectImpl(collection, body, options, this._conversationId);
|
|
1183
1188
|
}
|
|
1184
1189
|
/** Update an existing object. */
|
|
1185
|
-
async updateObject(
|
|
1186
|
-
return this._channel._updateObjectImpl(
|
|
1190
|
+
async updateObject(location, options) {
|
|
1191
|
+
return this._channel._updateObjectImpl(location, options, this._conversationId);
|
|
1192
|
+
}
|
|
1193
|
+
/** Move (rename/relocate) an object. */
|
|
1194
|
+
async moveObject(from, to, options) {
|
|
1195
|
+
return this._channel._moveObjectImpl(from, to, options, this._conversationId);
|
|
1187
1196
|
}
|
|
1188
|
-
/** Delete objects by
|
|
1189
|
-
async deleteObjects(
|
|
1190
|
-
return this._channel._deleteObjectsImpl(
|
|
1197
|
+
/** Delete objects by location. */
|
|
1198
|
+
async deleteObjects(locations) {
|
|
1199
|
+
return this._channel._deleteObjectsImpl(locations, this._conversationId);
|
|
1191
1200
|
}
|
|
1192
1201
|
/** Send a prompt to the AI agent, scoped to this conversation's history. */
|
|
1193
1202
|
async prompt(text, options) {
|