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