@rool-dev/sdk 0.8.7 → 0.9.0-dev.14c5d65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +357 -165
- package/dist/channel.d.ts +94 -184
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +356 -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 +57 -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 +112 -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 +30 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +70 -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 +33 -45
- package/dist/space.js.map +1 -1
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +9 -12
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +72 -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 { machineRef } from './machine.js';
|
|
4
|
+
// 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
|
|
5
|
+
const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
4
6
|
export function generateEntityId() {
|
|
5
7
|
let result = '';
|
|
6
8
|
for (let i = 0; i < 6; i++) {
|
|
7
|
-
result +=
|
|
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,43 @@ export class RoolChannel extends EventEmitter {
|
|
|
743
789
|
this._meta[key] = value;
|
|
744
790
|
this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
|
|
745
791
|
// Fire-and-forget server call
|
|
746
|
-
this.graphqlClient.setSpaceMeta(this.
|
|
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
|
+
attachmentRefs = await Promise.all(attachments.map((file) => this.uploadAttachment(file, conversationId)));
|
|
777
819
|
}
|
|
778
820
|
// Auto-continue from active leaf if no explicit parent provided
|
|
779
821
|
const parentInteractionId = explicitParent !== undefined
|
|
780
822
|
? explicitParent
|
|
781
823
|
: (this._getActiveLeafImpl(conversationId) ?? null);
|
|
782
|
-
const interactionId = generateEntityId();
|
|
783
824
|
// Optimistically set active leaf before the server call.
|
|
784
825
|
this._activeLeaves.set(conversationId, interactionId);
|
|
785
826
|
let onAbort;
|
|
786
827
|
if (signal) {
|
|
787
828
|
if (signal.aborted) {
|
|
788
|
-
// Caller aborted before we even started; fire-and-forget the stop so
|
|
789
|
-
// the server-side prompt (about to start) is cancelled too.
|
|
790
829
|
this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
|
|
791
830
|
}
|
|
792
831
|
else {
|
|
@@ -798,29 +837,33 @@ export class RoolChannel extends EventEmitter {
|
|
|
798
837
|
}
|
|
799
838
|
let result;
|
|
800
839
|
try {
|
|
801
|
-
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
840
|
+
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
841
|
+
...rest,
|
|
842
|
+
locations: locations?.map(normalizeLocation),
|
|
843
|
+
attachmentRefs,
|
|
844
|
+
interactionId,
|
|
845
|
+
parentInteractionId,
|
|
846
|
+
});
|
|
802
847
|
}
|
|
803
848
|
finally {
|
|
804
849
|
if (onAbort)
|
|
805
850
|
signal.removeEventListener('abort', onAbort);
|
|
806
851
|
}
|
|
807
852
|
// Collect modified objects — they arrive via SSE events during/after the mutation.
|
|
808
|
-
// Try collecting from buffer first, then fetch any missing from server.
|
|
809
853
|
const objects = [];
|
|
810
854
|
const missing = [];
|
|
811
|
-
for (const
|
|
812
|
-
const buffered = this._objectBuffer.get(
|
|
855
|
+
for (const location of result.modifiedObjectLocations) {
|
|
856
|
+
const buffered = this._objectBuffer.get(location);
|
|
813
857
|
if (buffered) {
|
|
814
|
-
this._objectBuffer.delete(
|
|
858
|
+
this._objectBuffer.delete(location);
|
|
815
859
|
objects.push(buffered);
|
|
816
860
|
}
|
|
817
861
|
else {
|
|
818
|
-
missing.push(
|
|
862
|
+
missing.push(location);
|
|
819
863
|
}
|
|
820
864
|
}
|
|
821
|
-
// Fetch any objects not yet received via SSE
|
|
822
865
|
if (missing.length > 0) {
|
|
823
|
-
const fetched = await Promise.all(missing.map(
|
|
866
|
+
const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
|
|
824
867
|
for (const obj of fetched) {
|
|
825
868
|
if (obj)
|
|
826
869
|
objects.push(obj);
|
|
@@ -831,119 +874,90 @@ export class RoolChannel extends EventEmitter {
|
|
|
831
874
|
objects,
|
|
832
875
|
};
|
|
833
876
|
}
|
|
834
|
-
/**
|
|
835
|
-
* Rename this channel.
|
|
836
|
-
*/
|
|
877
|
+
/** Rename this channel. */
|
|
837
878
|
async rename(newName) {
|
|
838
|
-
// Optimistic local update
|
|
839
879
|
const previousName = this._channel?.name;
|
|
840
880
|
if (this._channel) {
|
|
841
881
|
this._channel.name = newName;
|
|
842
882
|
}
|
|
843
883
|
this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
|
|
844
|
-
// Call server
|
|
845
884
|
try {
|
|
846
885
|
await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
|
|
847
886
|
}
|
|
848
887
|
catch (error) {
|
|
849
888
|
this.logger.error('[RoolChannel] Failed to rename channel:', error);
|
|
850
|
-
// Rollback
|
|
851
889
|
if (this._channel) {
|
|
852
890
|
this._channel.name = previousName;
|
|
853
891
|
}
|
|
854
892
|
throw error;
|
|
855
893
|
}
|
|
856
894
|
}
|
|
857
|
-
/**
|
|
858
|
-
* List all media files for this space.
|
|
859
|
-
*/
|
|
860
|
-
async listMedia() {
|
|
861
|
-
return this.mediaClient.list(this._id);
|
|
862
|
-
}
|
|
863
|
-
/**
|
|
864
|
-
* Upload a file to this space. Returns the URL.
|
|
865
|
-
*/
|
|
866
|
-
async uploadMedia(file) {
|
|
867
|
-
return this.mediaClient.upload(this._id, file);
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
871
|
-
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
872
|
-
* Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
|
|
873
|
-
*/
|
|
874
|
-
async fetchMedia(url, options) {
|
|
875
|
-
return this.mediaClient.fetch(this._id, url, options);
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Delete a media file by URL.
|
|
879
|
-
*/
|
|
880
|
-
async deleteMedia(url) {
|
|
881
|
-
return this.mediaClient.delete(this._id, url);
|
|
882
|
-
}
|
|
883
895
|
/**
|
|
884
896
|
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
885
|
-
* Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
|
|
886
|
-
*
|
|
887
|
-
* @param url - The URL to fetch
|
|
888
|
-
* @param init - Optional method, headers, and body
|
|
889
|
-
* @returns The proxied Response
|
|
890
897
|
*/
|
|
891
898
|
async fetch(url, init) {
|
|
892
|
-
return this.
|
|
899
|
+
return this.restClient.proxyFetch(this._id, url, init);
|
|
900
|
+
}
|
|
901
|
+
async uploadAttachment(file, conversationId) {
|
|
902
|
+
await this.ensureCollection('attachments');
|
|
903
|
+
const directory = `attachments/${conversationId}`;
|
|
904
|
+
await this.ensureCollection(directory);
|
|
905
|
+
const attachment = attachmentBody(file);
|
|
906
|
+
const path = `${directory}/${attachment.filename}`;
|
|
907
|
+
await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
|
|
908
|
+
return machineRef(`/rool-drive/${path}`);
|
|
909
|
+
}
|
|
910
|
+
async ensureCollection(path) {
|
|
911
|
+
// Note: not an object collection, a folder, which is "collection" in webdav land
|
|
912
|
+
const response = await this.webdav.request('MKCOL', path, { collection: true });
|
|
913
|
+
if (response.status === 201 || response.status === 405)
|
|
914
|
+
return;
|
|
915
|
+
throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
|
|
893
916
|
}
|
|
894
917
|
/**
|
|
895
918
|
* Register a collector that resolves when the object arrives via SSE.
|
|
896
|
-
* If the object is already in the buffer (arrived before collector), resolves immediately.
|
|
897
919
|
* @internal
|
|
898
920
|
*/
|
|
899
|
-
_collectObject(
|
|
921
|
+
_collectObject(location) {
|
|
900
922
|
return new Promise((resolve, reject) => {
|
|
901
|
-
|
|
902
|
-
const buffered = this._objectBuffer.get(objectId);
|
|
923
|
+
const buffered = this._objectBuffer.get(location);
|
|
903
924
|
if (buffered) {
|
|
904
|
-
this._objectBuffer.delete(
|
|
925
|
+
this._objectBuffer.delete(location);
|
|
905
926
|
resolve(buffered);
|
|
906
927
|
return;
|
|
907
928
|
}
|
|
908
929
|
const timer = setTimeout(() => {
|
|
909
|
-
this._objectResolvers.delete(
|
|
930
|
+
this._objectResolvers.delete(location);
|
|
910
931
|
// Fallback: try to fetch from server
|
|
911
|
-
this.graphqlClient.getObject(this._id,
|
|
932
|
+
this.graphqlClient.getObject(this._id, location).then(obj => {
|
|
912
933
|
if (obj) {
|
|
913
934
|
resolve(obj);
|
|
914
935
|
}
|
|
915
936
|
else {
|
|
916
|
-
reject(new Error(`Timeout waiting for object ${
|
|
937
|
+
reject(new Error(`Timeout waiting for object ${location} from SSE`));
|
|
917
938
|
}
|
|
918
939
|
}).catch(reject);
|
|
919
940
|
}, OBJECT_COLLECT_TIMEOUT);
|
|
920
|
-
this._objectResolvers.set(
|
|
941
|
+
this._objectResolvers.set(location, (obj) => {
|
|
921
942
|
clearTimeout(timer);
|
|
922
943
|
resolve(obj);
|
|
923
944
|
});
|
|
924
945
|
});
|
|
925
946
|
}
|
|
926
|
-
/**
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
_cancelCollector(objectId) {
|
|
931
|
-
this._objectResolvers.delete(objectId);
|
|
932
|
-
this._objectBuffer.delete(objectId);
|
|
947
|
+
/** @internal */
|
|
948
|
+
_cancelCollector(location) {
|
|
949
|
+
this._objectResolvers.delete(location);
|
|
950
|
+
this._objectBuffer.delete(location);
|
|
933
951
|
}
|
|
934
|
-
/**
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
*/
|
|
938
|
-
_deliverObject(objectId, object) {
|
|
939
|
-
const resolver = this._objectResolvers.get(objectId);
|
|
952
|
+
/** @internal */
|
|
953
|
+
_deliverObject(location, object) {
|
|
954
|
+
const resolver = this._objectResolvers.get(location);
|
|
940
955
|
if (resolver) {
|
|
941
956
|
resolver(object);
|
|
942
|
-
this._objectResolvers.delete(
|
|
957
|
+
this._objectResolvers.delete(location);
|
|
943
958
|
}
|
|
944
959
|
else {
|
|
945
|
-
|
|
946
|
-
this._objectBuffer.set(objectId, object);
|
|
960
|
+
this._objectBuffer.set(location, object);
|
|
947
961
|
}
|
|
948
962
|
}
|
|
949
963
|
/**
|
|
@@ -951,7 +965,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
951
965
|
* @internal
|
|
952
966
|
*/
|
|
953
967
|
handleChannelEvent(event) {
|
|
954
|
-
// Ignore events after close - the channel is being torn down
|
|
955
968
|
if (this._closed)
|
|
956
969
|
return;
|
|
957
970
|
const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
@@ -960,23 +973,31 @@ export class RoolChannel extends EventEmitter {
|
|
|
960
973
|
// Resync is handled by the client via _applyResyncData.
|
|
961
974
|
break;
|
|
962
975
|
case 'object_created':
|
|
963
|
-
if (event.
|
|
976
|
+
if (event.location && event.object) {
|
|
964
977
|
if (event.objectStat)
|
|
965
|
-
this._objectStats.set(event.
|
|
966
|
-
this._handleObjectCreated(event.
|
|
978
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
979
|
+
this._handleObjectCreated(event.location, event.object, changeSource);
|
|
967
980
|
}
|
|
968
981
|
break;
|
|
969
982
|
case 'object_updated':
|
|
970
|
-
if (event.
|
|
983
|
+
if (event.location && event.object) {
|
|
971
984
|
if (event.objectStat)
|
|
972
|
-
this._objectStats.set(event.
|
|
973
|
-
this._handleObjectUpdated(event.
|
|
985
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
986
|
+
this._handleObjectUpdated(event.location, event.object, changeSource);
|
|
974
987
|
}
|
|
975
988
|
break;
|
|
976
989
|
case 'object_deleted':
|
|
977
|
-
if (event.
|
|
978
|
-
this._objectStats.delete(event.
|
|
979
|
-
this._handleObjectDeleted(event.
|
|
990
|
+
if (event.location) {
|
|
991
|
+
this._objectStats.delete(event.location);
|
|
992
|
+
this._handleObjectDeleted(event.location, changeSource);
|
|
993
|
+
}
|
|
994
|
+
break;
|
|
995
|
+
case 'object_moved':
|
|
996
|
+
if (event.from && event.to && event.object) {
|
|
997
|
+
this._objectStats.delete(event.from);
|
|
998
|
+
if (event.objectStat)
|
|
999
|
+
this._objectStats.set(event.to, event.objectStat);
|
|
1000
|
+
this._handleObjectMoved(event.from, event.to, event.object, changeSource);
|
|
980
1001
|
}
|
|
981
1002
|
break;
|
|
982
1003
|
case 'schema_updated':
|
|
@@ -992,7 +1013,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
992
1013
|
}
|
|
993
1014
|
break;
|
|
994
1015
|
case 'channel_updated':
|
|
995
|
-
// Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
|
|
996
1016
|
if (event.channelId === this._channelId && event.channel) {
|
|
997
1017
|
const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
|
|
998
1018
|
this._channel = event.channel;
|
|
@@ -1002,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
1002
1022
|
}
|
|
1003
1023
|
break;
|
|
1004
1024
|
case 'conversation_updated':
|
|
1005
|
-
// Only update if it's our channel
|
|
1006
1025
|
if (event.channelId === this._channelId && event.conversationId) {
|
|
1007
1026
|
if (!this._channel) {
|
|
1008
1027
|
this._channel = {
|
|
@@ -1013,17 +1032,13 @@ export class RoolChannel extends EventEmitter {
|
|
|
1013
1032
|
}
|
|
1014
1033
|
const prev = this._channel.conversations[event.conversationId];
|
|
1015
1034
|
if (event.conversation) {
|
|
1016
|
-
// Update or create conversation in local cache
|
|
1017
1035
|
this._channel.conversations[event.conversationId] = event.conversation;
|
|
1018
1036
|
}
|
|
1019
1037
|
else {
|
|
1020
|
-
// Conversation was deleted
|
|
1021
1038
|
delete this._channel.conversations[event.conversationId];
|
|
1022
1039
|
}
|
|
1023
|
-
// Skip emit if data is unchanged (e.g. echo of our own optimistic update)
|
|
1024
1040
|
if (JSON.stringify(prev) === JSON.stringify(event.conversation))
|
|
1025
1041
|
break;
|
|
1026
|
-
// Auto-advance active leaf if someone continued our current branch
|
|
1027
1042
|
if (event.conversation && !Array.isArray(event.conversation.interactions)) {
|
|
1028
1043
|
const currentLeaf = this._getActiveLeafImpl(event.conversationId);
|
|
1029
1044
|
if (currentLeaf) {
|
|
@@ -1035,13 +1050,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
1035
1050
|
}
|
|
1036
1051
|
}
|
|
1037
1052
|
}
|
|
1038
|
-
// Emit the new conversationUpdated event
|
|
1039
1053
|
this.emit('conversationUpdated', {
|
|
1040
1054
|
conversationId: event.conversationId,
|
|
1041
1055
|
channelId: event.channelId,
|
|
1042
1056
|
source: changeSource,
|
|
1043
1057
|
});
|
|
1044
|
-
// Backward compat: also emit channelUpdated when the active conversation updates
|
|
1045
1058
|
if (event.conversationId === this._conversationId) {
|
|
1046
1059
|
this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
|
|
1047
1060
|
}
|
|
@@ -1052,87 +1065,75 @@ export class RoolChannel extends EventEmitter {
|
|
|
1052
1065
|
break;
|
|
1053
1066
|
}
|
|
1054
1067
|
}
|
|
1055
|
-
/**
|
|
1056
|
-
|
|
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);
|
|
1068
|
+
/** @internal */
|
|
1069
|
+
_handleObjectCreated(location, object, source) {
|
|
1070
|
+
this._deliverObject(location, object);
|
|
1071
|
+
// Maintain local location list — prepend (most recently modified first)
|
|
1072
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1073
|
+
const pending = this._pendingMutations.get(location);
|
|
1066
1074
|
if (pending !== undefined) {
|
|
1067
|
-
|
|
1068
|
-
this._pendingMutations.delete(objectId);
|
|
1075
|
+
this._pendingMutations.delete(location);
|
|
1069
1076
|
if (pending !== null) {
|
|
1070
|
-
//
|
|
1077
|
+
// Already emitted objectCreated optimistically.
|
|
1071
1078
|
// Emit objectUpdated only if AI resolved placeholders (data changed).
|
|
1072
1079
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1073
|
-
this.emit('objectUpdated', {
|
|
1080
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1074
1081
|
}
|
|
1075
1082
|
}
|
|
1076
1083
|
}
|
|
1077
1084
|
else {
|
|
1078
|
-
|
|
1079
|
-
this.emit('objectCreated', { objectId, object, source });
|
|
1085
|
+
this.emit('objectCreated', { location, object, source });
|
|
1080
1086
|
}
|
|
1081
1087
|
}
|
|
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);
|
|
1088
|
+
/** @internal */
|
|
1089
|
+
_handleObjectUpdated(location, object, source) {
|
|
1090
|
+
this._deliverObject(location, object);
|
|
1091
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1092
|
+
const pending = this._pendingMutations.get(location);
|
|
1093
1093
|
if (pending !== undefined) {
|
|
1094
|
-
|
|
1095
|
-
this._pendingMutations.delete(objectId);
|
|
1094
|
+
this._pendingMutations.delete(location);
|
|
1096
1095
|
if (pending !== null) {
|
|
1097
|
-
// Already emitted objectUpdated optimistically.
|
|
1098
|
-
// Emit again only if data changed (AI resolved placeholders).
|
|
1099
1096
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1100
|
-
this.emit('objectUpdated', {
|
|
1097
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1101
1098
|
}
|
|
1102
1099
|
}
|
|
1103
1100
|
}
|
|
1104
1101
|
else {
|
|
1105
|
-
|
|
1106
|
-
this.emit('objectUpdated', { objectId, object, source });
|
|
1102
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1107
1103
|
}
|
|
1108
1104
|
}
|
|
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);
|
|
1105
|
+
/** @internal */
|
|
1106
|
+
_handleObjectDeleted(location, source) {
|
|
1107
|
+
this._objectLocations = this._objectLocations.filter(l => l !== location);
|
|
1108
|
+
const pending = this._pendingMutations.get(location);
|
|
1118
1109
|
if (pending !== undefined) {
|
|
1119
|
-
|
|
1120
|
-
this._pendingMutations.delete(objectId);
|
|
1110
|
+
this._pendingMutations.delete(location);
|
|
1121
1111
|
}
|
|
1122
1112
|
else {
|
|
1123
|
-
|
|
1124
|
-
|
|
1113
|
+
this.emit('objectDeleted', { location, source });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/** @internal */
|
|
1117
|
+
_handleObjectMoved(from, to, object, source) {
|
|
1118
|
+
this._deliverObject(to, object);
|
|
1119
|
+
// Drop old location, insert new one at the front.
|
|
1120
|
+
this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
|
|
1121
|
+
const pending = this._pendingMutations.get(to);
|
|
1122
|
+
if (pending !== undefined) {
|
|
1123
|
+
this._pendingMutations.delete(to);
|
|
1124
|
+
if (pending !== null) {
|
|
1125
|
+
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1126
|
+
this.emit('objectUpdated', { location: to, object, source });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
this.emit('objectMoved', { from, to, object, source });
|
|
1125
1132
|
}
|
|
1126
1133
|
}
|
|
1127
1134
|
}
|
|
1128
1135
|
/**
|
|
1129
1136
|
* A lightweight handle for a specific conversation within a channel.
|
|
1130
|
-
*
|
|
1131
|
-
* Scopes AI and mutation operations to a particular conversation's interaction
|
|
1132
|
-
* history, while sharing the channel's single SSE connection and object state.
|
|
1133
|
-
*
|
|
1134
|
-
* Obtain via `channel.conversation('thread-id')`.
|
|
1135
|
-
* Conversations are auto-created on first interaction.
|
|
1136
1137
|
*/
|
|
1137
1138
|
export class ConversationHandle {
|
|
1138
1139
|
/** @internal */
|
|
@@ -1178,16 +1179,20 @@ export class ConversationHandle {
|
|
|
1178
1179
|
return this._channel._findObjectsImpl(options, this._conversationId);
|
|
1179
1180
|
}
|
|
1180
1181
|
/** Create a new object. */
|
|
1181
|
-
async createObject(options) {
|
|
1182
|
-
return this._channel._createObjectImpl(options, this._conversationId);
|
|
1182
|
+
async createObject(collection, body, options) {
|
|
1183
|
+
return this._channel._createObjectImpl(collection, body, options, this._conversationId);
|
|
1183
1184
|
}
|
|
1184
1185
|
/** Update an existing object. */
|
|
1185
|
-
async updateObject(
|
|
1186
|
-
return this._channel._updateObjectImpl(
|
|
1186
|
+
async updateObject(location, options) {
|
|
1187
|
+
return this._channel._updateObjectImpl(location, options, this._conversationId);
|
|
1188
|
+
}
|
|
1189
|
+
/** Move (rename/relocate) an object. */
|
|
1190
|
+
async moveObject(from, to, options) {
|
|
1191
|
+
return this._channel._moveObjectImpl(from, to, options, this._conversationId);
|
|
1187
1192
|
}
|
|
1188
|
-
/** Delete objects by
|
|
1189
|
-
async deleteObjects(
|
|
1190
|
-
return this._channel._deleteObjectsImpl(
|
|
1193
|
+
/** Delete objects by location. */
|
|
1194
|
+
async deleteObjects(locations) {
|
|
1195
|
+
return this._channel._deleteObjectsImpl(locations, this._conversationId);
|
|
1191
1196
|
}
|
|
1192
1197
|
/** Send a prompt to the AI agent, scoped to this conversation's history. */
|
|
1193
1198
|
async prompt(text, options) {
|