@rool-dev/sdk 0.2.0 → 0.3.0
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 +290 -178
- package/dist/channel.d.ts +316 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +793 -0
- package/dist/channel.js.map +1 -0
- package/dist/client.d.ts +39 -32
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +108 -70
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +41 -23
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +166 -97
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/space.d.ts +29 -268
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +49 -799
- package/dist/space.js.map +1 -1
- package/dist/subscription.d.ts +7 -7
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +40 -34
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +79 -85
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -2
package/dist/space.js
CHANGED
|
@@ -1,615 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
|
|
5
|
-
const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
6
|
-
export function generateEntityId() {
|
|
7
|
-
let result = '';
|
|
8
|
-
for (let i = 0; i < 6; i++) {
|
|
9
|
-
result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
|
|
10
|
-
}
|
|
11
|
-
return result;
|
|
12
|
-
}
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// RoolSpace — Lightweight handle for space-level admin operations
|
|
3
|
+
// =============================================================================
|
|
13
4
|
/**
|
|
14
|
-
*
|
|
5
|
+
* A space is a container for objects, schema, metadata, and channels.
|
|
15
6
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - Real-time updates via space-specific subscription
|
|
7
|
+
* RoolSpace is a lightweight handle for space-level admin operations:
|
|
8
|
+
* user management, link access, channel management, and export.
|
|
9
|
+
* It does not have a real-time subscription — use channels for live data.
|
|
10
|
+
*
|
|
11
|
+
* To work with objects and AI, open a channel on the space.
|
|
22
12
|
*/
|
|
23
|
-
export class RoolSpace
|
|
13
|
+
export class RoolSpace {
|
|
24
14
|
_id;
|
|
25
15
|
_name;
|
|
26
16
|
_role;
|
|
27
17
|
_linkAccess;
|
|
28
|
-
|
|
29
|
-
_conversationId;
|
|
30
|
-
_data;
|
|
31
|
-
_closed = false;
|
|
18
|
+
_channels;
|
|
32
19
|
graphqlClient;
|
|
33
20
|
mediaClient;
|
|
34
|
-
|
|
35
|
-
onCloseCallback;
|
|
36
|
-
_subscriptionReady;
|
|
37
|
-
logger;
|
|
21
|
+
_openChannelFn;
|
|
38
22
|
constructor(config) {
|
|
39
|
-
super();
|
|
40
23
|
this._id = config.id;
|
|
41
24
|
this._name = config.name;
|
|
42
25
|
this._role = config.role;
|
|
43
26
|
this._linkAccess = config.linkAccess;
|
|
44
|
-
this.
|
|
45
|
-
this._emitterLogger = config.logger;
|
|
46
|
-
this._conversationId = config.conversationId ?? generateEntityId();
|
|
47
|
-
this._data = config.initialData;
|
|
27
|
+
this._channels = config.channels;
|
|
48
28
|
this.graphqlClient = config.graphqlClient;
|
|
49
29
|
this.mediaClient = config.mediaClient;
|
|
50
|
-
this.
|
|
51
|
-
this.onCloseCallback = config.onClose;
|
|
52
|
-
// Create space-level subscription
|
|
53
|
-
this.subscriptionManager = new SpaceSubscriptionManager({
|
|
54
|
-
graphqlUrl: config.graphqlUrl,
|
|
55
|
-
authManager: config.authManager,
|
|
56
|
-
logger: this.logger,
|
|
57
|
-
spaceId: this._id,
|
|
58
|
-
conversationId: this._conversationId,
|
|
59
|
-
onEvent: (event) => this.handleSpaceEvent(event),
|
|
60
|
-
onConnectionStateChanged: () => {
|
|
61
|
-
// Space-level connection state (could emit events if needed)
|
|
62
|
-
},
|
|
63
|
-
onError: (error) => {
|
|
64
|
-
this.logger.error(`[RoolSpace ${this._id}] Subscription error:`, error);
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
// Start subscription - store promise for openSpace/createSpace to await
|
|
68
|
-
this._subscriptionReady = this.subscriptionManager.subscribe();
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Wait for the real-time subscription to be established.
|
|
72
|
-
* Called internally by openSpace/createSpace before returning the space.
|
|
73
|
-
* @internal
|
|
74
|
-
*/
|
|
75
|
-
_waitForSubscription() {
|
|
76
|
-
return this._subscriptionReady;
|
|
30
|
+
this._openChannelFn = config.openChannelFn;
|
|
77
31
|
}
|
|
78
32
|
// ===========================================================================
|
|
79
33
|
// Properties
|
|
80
34
|
// ===========================================================================
|
|
81
|
-
get id() {
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
get
|
|
85
|
-
return this._name;
|
|
86
|
-
}
|
|
87
|
-
get role() {
|
|
88
|
-
return this._role;
|
|
89
|
-
}
|
|
90
|
-
get linkAccess() {
|
|
91
|
-
return this._linkAccess;
|
|
92
|
-
}
|
|
93
|
-
/** Current user's ID (for identifying own interactions) */
|
|
94
|
-
get userId() {
|
|
95
|
-
return this._userId;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Get the conversation ID for this space instance.
|
|
99
|
-
* Used for AI context tracking and echo suppression.
|
|
100
|
-
*/
|
|
101
|
-
get conversationId() {
|
|
102
|
-
return this._conversationId;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Set the conversation ID for AI context tracking.
|
|
106
|
-
* Emits 'conversationIdChanged' event.
|
|
107
|
-
*/
|
|
108
|
-
set conversationId(value) {
|
|
109
|
-
if (value === this._conversationId)
|
|
110
|
-
return;
|
|
111
|
-
const previous = this._conversationId;
|
|
112
|
-
this._conversationId = value;
|
|
113
|
-
this.emit('conversationIdChanged', {
|
|
114
|
-
previousConversationId: previous,
|
|
115
|
-
newConversationId: value,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
get isReadOnly() {
|
|
119
|
-
return this._role === 'viewer';
|
|
120
|
-
}
|
|
35
|
+
get id() { return this._id; }
|
|
36
|
+
get name() { return this._name; }
|
|
37
|
+
get role() { return this._role; }
|
|
38
|
+
get linkAccess() { return this._linkAccess; }
|
|
121
39
|
// ===========================================================================
|
|
122
|
-
//
|
|
40
|
+
// Channel Lifecycle
|
|
123
41
|
// ===========================================================================
|
|
124
42
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*/
|
|
128
|
-
getInteractions() {
|
|
129
|
-
return this._data.conversations?.[this._conversationId]?.interactions ?? [];
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Get interactions for a specific conversation ID.
|
|
133
|
-
* Useful for viewing other conversations in the space.
|
|
134
|
-
*/
|
|
135
|
-
getInteractionsById(conversationId) {
|
|
136
|
-
return this._data.conversations?.[conversationId]?.interactions ?? [];
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Get all conversation IDs that have conversations in this space.
|
|
43
|
+
* Open a channel on this space.
|
|
44
|
+
* If the channel doesn't exist, the server creates it.
|
|
140
45
|
*/
|
|
141
|
-
|
|
142
|
-
return
|
|
46
|
+
async openChannel(channelId) {
|
|
47
|
+
return this._openChannelFn(this._id, channelId);
|
|
143
48
|
}
|
|
144
49
|
// ===========================================================================
|
|
145
|
-
// Space
|
|
50
|
+
// Space Admin
|
|
146
51
|
// ===========================================================================
|
|
147
52
|
/**
|
|
148
53
|
* Rename this space.
|
|
149
54
|
*/
|
|
150
55
|
async rename(newName) {
|
|
151
|
-
|
|
56
|
+
await this.graphqlClient.renameSpace(this._id, newName);
|
|
152
57
|
this._name = newName;
|
|
153
|
-
try {
|
|
154
|
-
await this.graphqlClient.renameSpace(this._id, newName);
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
this._name = oldName;
|
|
158
|
-
throw error;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Close this space and clean up resources.
|
|
163
|
-
* Stops real-time subscription and unregisters from client.
|
|
164
|
-
*/
|
|
165
|
-
close() {
|
|
166
|
-
this._closed = true;
|
|
167
|
-
this.subscriptionManager.destroy();
|
|
168
|
-
this.onCloseCallback(this._id);
|
|
169
|
-
this.removeAllListeners();
|
|
170
|
-
}
|
|
171
|
-
// ===========================================================================
|
|
172
|
-
// Undo / Redo (Server-managed checkpoints)
|
|
173
|
-
// ===========================================================================
|
|
174
|
-
/**
|
|
175
|
-
* Create a checkpoint (seal current batch of changes).
|
|
176
|
-
* Patches accumulate automatically - this seals them with a label.
|
|
177
|
-
* @returns The checkpoint ID
|
|
178
|
-
*/
|
|
179
|
-
async checkpoint(label = 'Change') {
|
|
180
|
-
const result = await this.graphqlClient.checkpoint(this._id, label, this._conversationId);
|
|
181
|
-
return result.checkpointId;
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Check if undo is available.
|
|
185
|
-
*/
|
|
186
|
-
async canUndo() {
|
|
187
|
-
const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
|
|
188
|
-
return status.canUndo;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Check if redo is available.
|
|
192
|
-
*/
|
|
193
|
-
async canRedo() {
|
|
194
|
-
const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
|
|
195
|
-
return status.canRedo;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Undo the most recent batch of changes.
|
|
199
|
-
* Reverses your most recent batch (sealed or open).
|
|
200
|
-
* Conflicting patches (modified by others) are silently skipped.
|
|
201
|
-
* @returns true if undo was performed
|
|
202
|
-
*/
|
|
203
|
-
async undo() {
|
|
204
|
-
const result = await this.graphqlClient.undo(this._id, this._conversationId);
|
|
205
|
-
// Server broadcasts space_patched if successful, which updates local state
|
|
206
|
-
return result.success;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Redo a previously undone batch of changes.
|
|
210
|
-
* @returns true if redo was performed
|
|
211
|
-
*/
|
|
212
|
-
async redo() {
|
|
213
|
-
const result = await this.graphqlClient.redo(this._id, this._conversationId);
|
|
214
|
-
// Server broadcasts space_patched if successful, which updates local state
|
|
215
|
-
return result.success;
|
|
216
58
|
}
|
|
217
59
|
/**
|
|
218
|
-
*
|
|
60
|
+
* Delete this space permanently. Cannot be undone.
|
|
219
61
|
*/
|
|
220
|
-
async
|
|
221
|
-
await this.graphqlClient.
|
|
62
|
+
async delete() {
|
|
63
|
+
await this.graphqlClient.deleteSpace(this._id);
|
|
222
64
|
}
|
|
223
65
|
// ===========================================================================
|
|
224
|
-
//
|
|
225
|
-
// ===========================================================================
|
|
226
|
-
/**
|
|
227
|
-
* Get an object's data by ID.
|
|
228
|
-
* Returns just the data portion (RoolObject), not the full entry with meta/links.
|
|
229
|
-
*/
|
|
230
|
-
async getObject(objectId) {
|
|
231
|
-
return this._data.objects[objectId]?.data;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Get an object's stat (audit information).
|
|
235
|
-
* Returns modification timestamp and author, or undefined if object not found.
|
|
236
|
-
*/
|
|
237
|
-
async stat(objectId) {
|
|
238
|
-
const entry = this._data.objects[objectId];
|
|
239
|
-
if (!entry)
|
|
240
|
-
return undefined;
|
|
241
|
-
return {
|
|
242
|
-
modifiedAt: entry.modifiedAt,
|
|
243
|
-
modifiedBy: entry.modifiedBy,
|
|
244
|
-
modifiedByName: entry.modifiedByName,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Find objects using structured filters and/or natural language.
|
|
249
|
-
*
|
|
250
|
-
* `where` provides exact-match filtering — values must match literally (no placeholders or operators).
|
|
251
|
-
* `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
|
|
252
|
-
* constrain the data set before the AI sees it.
|
|
253
|
-
*
|
|
254
|
-
* @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
|
|
255
|
-
* @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
|
|
256
|
-
* @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
|
|
257
|
-
* @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
|
|
258
|
-
* @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
|
|
259
|
-
* @param options.ephemeral - If true, the query won't be recorded in conversation history.
|
|
260
|
-
* @returns The matching objects and a descriptive message.
|
|
261
|
-
*
|
|
262
|
-
* @example
|
|
263
|
-
* // Exact match (no AI, no credits)
|
|
264
|
-
* const { objects } = await space.findObjects({ where: { type: 'article' } });
|
|
265
|
-
*
|
|
266
|
-
* @example
|
|
267
|
-
* // Natural language (AI query)
|
|
268
|
-
* const { objects, message } = await space.findObjects({
|
|
269
|
-
* prompt: 'articles about space exploration'
|
|
270
|
-
* });
|
|
271
|
-
*
|
|
272
|
-
* @example
|
|
273
|
-
* // Combined — where narrows the data, prompt queries within it
|
|
274
|
-
* const { objects } = await space.findObjects({
|
|
275
|
-
* where: { type: 'article' },
|
|
276
|
-
* prompt: 'that discuss climate solutions positively',
|
|
277
|
-
* limit: 10
|
|
278
|
-
* });
|
|
279
|
-
*/
|
|
280
|
-
async findObjects(options) {
|
|
281
|
-
return this.graphqlClient.findObjects(this._id, options, this._conversationId);
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Get all object IDs.
|
|
285
|
-
* @param options.limit - Maximum number of IDs to return
|
|
286
|
-
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
287
|
-
*/
|
|
288
|
-
getObjectIds(options) {
|
|
289
|
-
const order = options?.order ?? 'desc';
|
|
290
|
-
let entries = Object.entries(this._data.objects);
|
|
291
|
-
// Sort by modifiedAt
|
|
292
|
-
entries.sort((a, b) => {
|
|
293
|
-
const aTime = a[1].modifiedAt ?? 0;
|
|
294
|
-
const bTime = b[1].modifiedAt ?? 0;
|
|
295
|
-
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
296
|
-
});
|
|
297
|
-
let ids = entries.map(([id]) => id);
|
|
298
|
-
if (options?.limit) {
|
|
299
|
-
ids = ids.slice(0, options.limit);
|
|
300
|
-
}
|
|
301
|
-
return ids;
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Create a new object with optional AI generation.
|
|
305
|
-
* @param options.data - Object data fields (any key-value pairs). Optionally include `id` to use a custom ID. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
|
|
306
|
-
* @param options.ephemeral - If true, the operation won't be recorded in conversation history.
|
|
307
|
-
* @returns The created object (with AI-filled content) and message
|
|
308
|
-
*/
|
|
309
|
-
async createObject(options) {
|
|
310
|
-
const { data, ephemeral } = options;
|
|
311
|
-
// Use data.id if provided (string), otherwise generate
|
|
312
|
-
const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
|
|
313
|
-
// Validate ID format: alphanumeric, hyphens, underscores only
|
|
314
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
|
|
315
|
-
throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
|
|
316
|
-
}
|
|
317
|
-
// Fail if object already exists
|
|
318
|
-
if (this._data.objects[objectId]) {
|
|
319
|
-
throw new Error(`Object "${objectId}" already exists`);
|
|
320
|
-
}
|
|
321
|
-
const dataWithId = { ...data, id: objectId };
|
|
322
|
-
// Build the entry for local state (optimistic - server will overwrite audit fields)
|
|
323
|
-
const entry = {
|
|
324
|
-
data: dataWithId,
|
|
325
|
-
modifiedAt: Date.now(),
|
|
326
|
-
modifiedBy: this._userId,
|
|
327
|
-
modifiedByName: null,
|
|
328
|
-
};
|
|
329
|
-
// Update local state immediately (optimistic)
|
|
330
|
-
this._data.objects[objectId] = entry;
|
|
331
|
-
this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
|
|
332
|
-
// Await server call (may trigger AI processing that updates local state via patches)
|
|
333
|
-
try {
|
|
334
|
-
const message = await this.graphqlClient.createObject(this.id, dataWithId, this._conversationId, ephemeral);
|
|
335
|
-
// Return current state (may have been updated by AI patches)
|
|
336
|
-
return { object: this._data.objects[objectId].data, message };
|
|
337
|
-
}
|
|
338
|
-
catch (error) {
|
|
339
|
-
this.logger.error('[RoolSpace] Failed to create object:', error);
|
|
340
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
341
|
-
throw error;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* Update an existing object.
|
|
346
|
-
* @param objectId - The ID of the object to update
|
|
347
|
-
* @param options.data - Fields to add or update. Pass null or undefined to delete a field. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
|
|
348
|
-
* @param options.prompt - AI prompt for content editing (optional).
|
|
349
|
-
* @param options.ephemeral - If true, the operation won't be recorded in conversation history.
|
|
350
|
-
* @returns The updated object (with AI-filled content) and message
|
|
351
|
-
*/
|
|
352
|
-
async updateObject(objectId, options) {
|
|
353
|
-
const entry = this._data.objects[objectId];
|
|
354
|
-
if (!entry) {
|
|
355
|
-
throw new Error(`Object ${objectId} not found for update`);
|
|
356
|
-
}
|
|
357
|
-
const { data, ephemeral } = options;
|
|
358
|
-
// id is immutable after creation (but null/undefined means delete attempt, which we also reject)
|
|
359
|
-
if (data?.id !== undefined && data.id !== null) {
|
|
360
|
-
throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
|
|
361
|
-
}
|
|
362
|
-
if (data && ('id' in data)) {
|
|
363
|
-
throw new Error('Cannot delete id field. The id field is immutable after creation.');
|
|
364
|
-
}
|
|
365
|
-
// Normalize undefined to null (for JSON serialization) and build server data
|
|
366
|
-
let serverData;
|
|
367
|
-
if (data) {
|
|
368
|
-
serverData = {};
|
|
369
|
-
for (const [key, value] of Object.entries(data)) {
|
|
370
|
-
// Convert undefined to null for wire protocol
|
|
371
|
-
serverData[key] = value === undefined ? null : value;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
// Build local updates (apply deletions and updates)
|
|
375
|
-
if (data) {
|
|
376
|
-
for (const [key, value] of Object.entries(data)) {
|
|
377
|
-
if (value === null || value === undefined) {
|
|
378
|
-
delete entry.data[key];
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
entry.data[key] = value;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
// Emit semantic event with updated object
|
|
386
|
-
if (data) {
|
|
387
|
-
this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
|
|
388
|
-
}
|
|
389
|
-
// Await server call (may trigger AI processing that updates local state via patches)
|
|
390
|
-
try {
|
|
391
|
-
const message = await this.graphqlClient.updateObject(this.id, objectId, this._conversationId, serverData, options.prompt, ephemeral);
|
|
392
|
-
// Return current state (may have been updated by AI patches)
|
|
393
|
-
return { object: this._data.objects[objectId].data, message };
|
|
394
|
-
}
|
|
395
|
-
catch (error) {
|
|
396
|
-
this.logger.error('[RoolSpace] Failed to update object:', error);
|
|
397
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
398
|
-
throw error;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* Delete objects by IDs.
|
|
403
|
-
* Other objects that reference deleted objects via data fields will retain stale ref values.
|
|
404
|
-
*/
|
|
405
|
-
async deleteObjects(objectIds) {
|
|
406
|
-
if (objectIds.length === 0)
|
|
407
|
-
return;
|
|
408
|
-
const deletedObjectIds = [];
|
|
409
|
-
// Remove objects (local state)
|
|
410
|
-
for (const objectId of objectIds) {
|
|
411
|
-
if (this._data.objects[objectId]) {
|
|
412
|
-
delete this._data.objects[objectId];
|
|
413
|
-
deletedObjectIds.push(objectId);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
// Emit semantic events
|
|
417
|
-
for (const objectId of deletedObjectIds) {
|
|
418
|
-
this.emit('objectDeleted', { objectId, source: 'local_user' });
|
|
419
|
-
}
|
|
420
|
-
// Await server call
|
|
421
|
-
try {
|
|
422
|
-
await this.graphqlClient.deleteObjects(this.id, objectIds, this._conversationId);
|
|
423
|
-
}
|
|
424
|
-
catch (error) {
|
|
425
|
-
this.logger.error('[RoolSpace] Failed to delete objects:', error);
|
|
426
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
427
|
-
throw error;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
// ===========================================================================
|
|
431
|
-
// Conversation Management
|
|
432
|
-
// ===========================================================================
|
|
433
|
-
/**
|
|
434
|
-
* Delete a conversation and its interaction history.
|
|
435
|
-
* Defaults to the current conversation if no conversationId is provided.
|
|
436
|
-
*/
|
|
437
|
-
async deleteConversation(conversationId) {
|
|
438
|
-
const targetConversationId = conversationId ?? this._conversationId;
|
|
439
|
-
// Optimistic local update
|
|
440
|
-
if (this._data.conversations?.[targetConversationId]) {
|
|
441
|
-
delete this._data.conversations[targetConversationId];
|
|
442
|
-
}
|
|
443
|
-
// Emit events
|
|
444
|
-
this.emit('conversationUpdated', {
|
|
445
|
-
conversationId: targetConversationId,
|
|
446
|
-
source: 'local_user',
|
|
447
|
-
});
|
|
448
|
-
this.emit('conversationsChanged', {
|
|
449
|
-
action: 'deleted',
|
|
450
|
-
conversationId: targetConversationId,
|
|
451
|
-
source: 'local_user',
|
|
452
|
-
});
|
|
453
|
-
// Call server
|
|
454
|
-
try {
|
|
455
|
-
await this.graphqlClient.deleteConversation(this.id, targetConversationId);
|
|
456
|
-
}
|
|
457
|
-
catch (error) {
|
|
458
|
-
this.logger.error('[RoolSpace] Failed to delete conversation:', error);
|
|
459
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
460
|
-
throw error;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Rename a conversation.
|
|
465
|
-
* If the conversation doesn't exist, it will be created with the given name.
|
|
466
|
-
*/
|
|
467
|
-
async renameConversation(conversationId, name) {
|
|
468
|
-
// Optimistic local update - auto-create if needed
|
|
469
|
-
if (!this._data.conversations) {
|
|
470
|
-
this._data.conversations = {};
|
|
471
|
-
}
|
|
472
|
-
const isNew = !this._data.conversations[conversationId];
|
|
473
|
-
if (isNew) {
|
|
474
|
-
this._data.conversations[conversationId] = {
|
|
475
|
-
name,
|
|
476
|
-
createdAt: Date.now(),
|
|
477
|
-
createdBy: this._userId,
|
|
478
|
-
interactions: [],
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
this._data.conversations[conversationId].name = name;
|
|
483
|
-
}
|
|
484
|
-
// Emit events
|
|
485
|
-
this.emit('conversationUpdated', {
|
|
486
|
-
conversationId,
|
|
487
|
-
source: 'local_user',
|
|
488
|
-
});
|
|
489
|
-
this.emit('conversationsChanged', {
|
|
490
|
-
action: isNew ? 'created' : 'renamed',
|
|
491
|
-
conversationId,
|
|
492
|
-
name,
|
|
493
|
-
source: 'local_user',
|
|
494
|
-
});
|
|
495
|
-
// Call server
|
|
496
|
-
try {
|
|
497
|
-
await this.graphqlClient.renameConversation(this.id, conversationId, name);
|
|
498
|
-
}
|
|
499
|
-
catch (error) {
|
|
500
|
-
this.logger.error('[RoolSpace] Failed to rename conversation:', error);
|
|
501
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
502
|
-
throw error;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
/**
|
|
506
|
-
* List all conversations in this space with summary info.
|
|
507
|
-
*/
|
|
508
|
-
async listConversations() {
|
|
509
|
-
return this.graphqlClient.listConversations(this.id);
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Get the system instruction for the current conversation.
|
|
513
|
-
* Returns undefined if no system instruction is set.
|
|
514
|
-
*/
|
|
515
|
-
getSystemInstruction() {
|
|
516
|
-
return this._data.conversations?.[this._conversationId]?.systemInstruction;
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Set the system instruction for the current conversation.
|
|
520
|
-
* Pass null to clear the instruction.
|
|
521
|
-
*/
|
|
522
|
-
async setSystemInstruction(instruction) {
|
|
523
|
-
// Optimistic local update
|
|
524
|
-
if (!this._data.conversations) {
|
|
525
|
-
this._data.conversations = {};
|
|
526
|
-
}
|
|
527
|
-
if (!this._data.conversations[this._conversationId]) {
|
|
528
|
-
this._data.conversations[this._conversationId] = {
|
|
529
|
-
createdAt: Date.now(),
|
|
530
|
-
createdBy: this._userId,
|
|
531
|
-
interactions: [],
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
if (instruction === null) {
|
|
535
|
-
delete this._data.conversations[this._conversationId].systemInstruction;
|
|
536
|
-
}
|
|
537
|
-
else {
|
|
538
|
-
this._data.conversations[this._conversationId].systemInstruction = instruction;
|
|
539
|
-
}
|
|
540
|
-
// Emit event
|
|
541
|
-
this.emit('conversationUpdated', {
|
|
542
|
-
conversationId: this._conversationId,
|
|
543
|
-
source: 'local_user',
|
|
544
|
-
});
|
|
545
|
-
// Call server
|
|
546
|
-
try {
|
|
547
|
-
await this.graphqlClient.setSystemInstruction(this.id, this._conversationId, instruction);
|
|
548
|
-
}
|
|
549
|
-
catch (error) {
|
|
550
|
-
this.logger.error('[RoolSpace] Failed to set system instruction:', error);
|
|
551
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
552
|
-
throw error;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
// ===========================================================================
|
|
556
|
-
// Metadata Operations
|
|
557
|
-
// ===========================================================================
|
|
558
|
-
/**
|
|
559
|
-
* Set a space-level metadata value.
|
|
560
|
-
* Metadata is stored in meta and hidden from AI operations.
|
|
561
|
-
*/
|
|
562
|
-
setMetadata(key, value) {
|
|
563
|
-
if (!this._data.meta) {
|
|
564
|
-
this._data.meta = {};
|
|
565
|
-
}
|
|
566
|
-
this._data.meta[key] = value;
|
|
567
|
-
this.emit('metadataUpdated', { metadata: this._data.meta, source: 'local_user' });
|
|
568
|
-
// Fire-and-forget server call - errors trigger resync
|
|
569
|
-
this.graphqlClient.setSpaceMeta(this.id, this._data.meta, this._conversationId)
|
|
570
|
-
.catch((error) => {
|
|
571
|
-
this.logger.error('[RoolSpace] Failed to set meta:', error);
|
|
572
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Get a space-level metadata value.
|
|
577
|
-
*/
|
|
578
|
-
getMetadata(key) {
|
|
579
|
-
return this._data.meta?.[key];
|
|
580
|
-
}
|
|
581
|
-
/**
|
|
582
|
-
* Get all space-level metadata.
|
|
583
|
-
*/
|
|
584
|
-
getAllMetadata() {
|
|
585
|
-
return this._data.meta ?? {};
|
|
586
|
-
}
|
|
587
|
-
// ===========================================================================
|
|
588
|
-
// AI Operations
|
|
589
|
-
// ===========================================================================
|
|
590
|
-
/**
|
|
591
|
-
* Send a prompt to the AI agent for space manipulation.
|
|
592
|
-
* @returns The message from the AI and the list of objects that were created or modified
|
|
593
|
-
*/
|
|
594
|
-
async prompt(prompt, options) {
|
|
595
|
-
// Upload attachments via media endpoint, then send URLs to the server
|
|
596
|
-
const { attachments, ...rest } = options ?? {};
|
|
597
|
-
let attachmentUrls;
|
|
598
|
-
if (attachments?.length) {
|
|
599
|
-
attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
|
|
600
|
-
}
|
|
601
|
-
const result = await this.graphqlClient.prompt(this._id, prompt, this._conversationId, { ...rest, attachmentUrls });
|
|
602
|
-
// Hydrate modified object IDs to actual objects (filter out deleted ones)
|
|
603
|
-
const objects = result.modifiedObjectIds
|
|
604
|
-
.map(id => this._data.objects[id]?.data)
|
|
605
|
-
.filter((obj) => obj !== undefined);
|
|
606
|
-
return {
|
|
607
|
-
message: result.message,
|
|
608
|
-
objects,
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
// ===========================================================================
|
|
612
|
-
// Collaboration
|
|
66
|
+
// User Management
|
|
613
67
|
// ===========================================================================
|
|
614
68
|
/**
|
|
615
69
|
* List users with access to this space.
|
|
@@ -632,7 +86,6 @@ export class RoolSpace extends EventEmitter {
|
|
|
632
86
|
/**
|
|
633
87
|
* Set the link sharing level for this space.
|
|
634
88
|
* Requires owner or admin role.
|
|
635
|
-
* @param linkAccess - 'none' (no link access), 'viewer' (read-only), or 'editor' (full edit)
|
|
636
89
|
*/
|
|
637
90
|
async setLinkAccess(linkAccess) {
|
|
638
91
|
const previous = this._linkAccess;
|
|
@@ -646,248 +99,45 @@ export class RoolSpace extends EventEmitter {
|
|
|
646
99
|
}
|
|
647
100
|
}
|
|
648
101
|
// ===========================================================================
|
|
649
|
-
//
|
|
102
|
+
// Channel Management
|
|
650
103
|
// ===========================================================================
|
|
651
104
|
/**
|
|
652
|
-
* List
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
return this.mediaClient.list(this._id);
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Upload a file to this space. Returns the URL.
|
|
659
|
-
*/
|
|
660
|
-
async uploadMedia(file) {
|
|
661
|
-
return this.mediaClient.upload(this._id, file);
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
665
|
-
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
666
|
-
*/
|
|
667
|
-
async fetchMedia(url) {
|
|
668
|
-
return this.mediaClient.fetch(this._id, url);
|
|
669
|
-
}
|
|
670
|
-
/**
|
|
671
|
-
* Delete a media file by URL.
|
|
105
|
+
* List channels in this space.
|
|
106
|
+
* Returns from cached snapshot (populated at open time).
|
|
107
|
+
* Call refresh() to update from server.
|
|
672
108
|
*/
|
|
673
|
-
|
|
674
|
-
return this.
|
|
109
|
+
getChannels() {
|
|
110
|
+
return this._channels;
|
|
675
111
|
}
|
|
676
|
-
// ===========================================================================
|
|
677
|
-
// Low-level Operations
|
|
678
|
-
// ===========================================================================
|
|
679
112
|
/**
|
|
680
|
-
*
|
|
681
|
-
* Use sparingly - prefer specific operations.
|
|
113
|
+
* Delete a channel from this space.
|
|
682
114
|
*/
|
|
683
|
-
|
|
684
|
-
|
|
115
|
+
async deleteChannel(channelId) {
|
|
116
|
+
await this.graphqlClient.deleteChannel(this._id, channelId);
|
|
117
|
+
this._channels = this._channels.filter(c => c.id !== channelId);
|
|
685
118
|
}
|
|
686
119
|
// ===========================================================================
|
|
687
|
-
//
|
|
120
|
+
// Export
|
|
688
121
|
// ===========================================================================
|
|
689
122
|
/**
|
|
690
123
|
* Export space data and media as a zip archive.
|
|
691
|
-
* The archive contains a data.json file with objects, relations, metadata,
|
|
692
|
-
* and conversations, plus a media/ folder with all media files.
|
|
693
|
-
* @returns A Blob containing the zip archive
|
|
694
124
|
*/
|
|
695
125
|
async exportArchive() {
|
|
696
126
|
return this.mediaClient.exportArchive(this._id);
|
|
697
127
|
}
|
|
698
128
|
// ===========================================================================
|
|
699
|
-
//
|
|
129
|
+
// Refresh
|
|
700
130
|
// ===========================================================================
|
|
701
131
|
/**
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*/
|
|
705
|
-
handleSpaceEvent(event) {
|
|
706
|
-
// Ignore events after close - the space is being torn down
|
|
707
|
-
if (this._closed)
|
|
708
|
-
return;
|
|
709
|
-
switch (event.type) {
|
|
710
|
-
case 'space_patched':
|
|
711
|
-
if (event.patch) {
|
|
712
|
-
this.handleRemotePatch(event.patch, event.source);
|
|
713
|
-
}
|
|
714
|
-
break;
|
|
715
|
-
case 'space_changed':
|
|
716
|
-
// Full reload needed
|
|
717
|
-
void this.graphqlClient.getSpace(this._id).then(({ data }) => {
|
|
718
|
-
this._data = data;
|
|
719
|
-
this.emit('reset', { source: 'remote_user' });
|
|
720
|
-
});
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Check if a patch would actually change the current data.
|
|
726
|
-
* Used to deduplicate events when patches don't change anything (e.g., optimistic updates).
|
|
727
|
-
* @internal
|
|
728
|
-
*/
|
|
729
|
-
didPatchChangeAnything(patch) {
|
|
730
|
-
for (const op of patch) {
|
|
731
|
-
const pathParts = op.path.split('/').filter(p => p);
|
|
732
|
-
let current = this._data;
|
|
733
|
-
for (const part of pathParts) {
|
|
734
|
-
current = current?.[part];
|
|
735
|
-
}
|
|
736
|
-
if (op.op === 'remove' && current !== undefined)
|
|
737
|
-
return true;
|
|
738
|
-
if ((op.op === 'add' || op.op === 'replace') &&
|
|
739
|
-
JSON.stringify(current) !== JSON.stringify(op.value))
|
|
740
|
-
return true;
|
|
741
|
-
}
|
|
742
|
-
return false;
|
|
743
|
-
}
|
|
744
|
-
/**
|
|
745
|
-
* Handle a patch event from another client.
|
|
746
|
-
* Checks for version gaps to detect missed patches.
|
|
747
|
-
* @internal
|
|
748
|
-
*/
|
|
749
|
-
handleRemotePatch(patch, source) {
|
|
750
|
-
// Extract the new version from the patch
|
|
751
|
-
const versionOp = patch.find(op => op.path === '/version' && (op.op === 'add' || op.op === 'replace'));
|
|
752
|
-
if (versionOp) {
|
|
753
|
-
const incomingVersion = versionOp.value;
|
|
754
|
-
const currentVersion = this._data.version ?? 0;
|
|
755
|
-
const expectedVersion = currentVersion + 1;
|
|
756
|
-
// Check for version gap (missed patches)
|
|
757
|
-
if (incomingVersion > expectedVersion) {
|
|
758
|
-
this.logger.warn(`[RoolSpace] Version gap detected: expected ${expectedVersion}, got ${incomingVersion}. Resyncing.`);
|
|
759
|
-
this.resyncFromServer(new Error(`Version gap: expected ${expectedVersion}, got ${incomingVersion}`))
|
|
760
|
-
.catch(() => { });
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
// Skip stale patches (version <= current, already applied)
|
|
764
|
-
if (incomingVersion <= currentVersion) {
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// Check if patch would change anything BEFORE applying
|
|
769
|
-
const willChange = this.didPatchChangeAnything(patch);
|
|
770
|
-
try {
|
|
771
|
-
this._data = immutableJSONPatch(this._data, patch);
|
|
772
|
-
}
|
|
773
|
-
catch (error) {
|
|
774
|
-
this.logger.error('[RoolSpace] Failed to apply remote patch:', error);
|
|
775
|
-
// Force resync on patch error
|
|
776
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
// Only emit events if something actually changed
|
|
780
|
-
if (willChange) {
|
|
781
|
-
const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
782
|
-
this.emitSemanticEventsFromPatch(patch, changeSource);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
/**
|
|
786
|
-
* Parse JSON patch operations and emit semantic events.
|
|
787
|
-
* @internal
|
|
132
|
+
* Refresh space data from the server.
|
|
133
|
+
* Updates name, role, linkAccess, and channel list.
|
|
788
134
|
*/
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if (path.startsWith('/objects/')) {
|
|
796
|
-
const parts = path.split('/');
|
|
797
|
-
const objectId = parts[2];
|
|
798
|
-
if (parts.length === 3) {
|
|
799
|
-
// /objects/{objectId} - full object add or remove
|
|
800
|
-
if (op.op === 'add') {
|
|
801
|
-
const entry = this._data.objects[objectId];
|
|
802
|
-
if (entry) {
|
|
803
|
-
this.emit('objectCreated', { objectId, object: entry.data, source });
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
else if (op.op === 'remove') {
|
|
807
|
-
this.emit('objectDeleted', { objectId, source });
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
else if (parts[3] === 'data') {
|
|
811
|
-
// /objects/{objectId}/data/... - data field update
|
|
812
|
-
if (!updatedObjects.has(objectId)) {
|
|
813
|
-
const entry = this._data.objects[objectId];
|
|
814
|
-
if (entry) {
|
|
815
|
-
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
816
|
-
updatedObjects.add(objectId);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
else if (path === '/meta' || path.startsWith('/meta/')) {
|
|
822
|
-
this.emit('metadataUpdated', { metadata: this._data.meta, source });
|
|
823
|
-
}
|
|
824
|
-
// Conversation operations: /conversations/{conversationId} or /conversations/{conversationId}/...
|
|
825
|
-
else if (path.startsWith('/conversations/')) {
|
|
826
|
-
const parts = path.split('/');
|
|
827
|
-
const conversationId = parts[2];
|
|
828
|
-
if (conversationId) {
|
|
829
|
-
this.emit('conversationUpdated', { conversationId, source });
|
|
830
|
-
// Emit conversationsChanged for list-level changes
|
|
831
|
-
if (parts.length === 3) {
|
|
832
|
-
// /conversations/{conversationId} - full conversation add or remove
|
|
833
|
-
if (op.op === 'add') {
|
|
834
|
-
const conv = this._data.conversations?.[conversationId];
|
|
835
|
-
this.emit('conversationsChanged', {
|
|
836
|
-
action: 'created',
|
|
837
|
-
conversationId,
|
|
838
|
-
name: conv?.name,
|
|
839
|
-
source,
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
|
-
else if (op.op === 'remove') {
|
|
843
|
-
this.emit('conversationsChanged', {
|
|
844
|
-
action: 'deleted',
|
|
845
|
-
conversationId,
|
|
846
|
-
source,
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
else if (parts[3] === 'name') {
|
|
851
|
-
// /conversations/{conversationId}/name - rename
|
|
852
|
-
const conv = this._data.conversations?.[conversationId];
|
|
853
|
-
this.emit('conversationsChanged', {
|
|
854
|
-
action: 'renamed',
|
|
855
|
-
conversationId,
|
|
856
|
-
name: conv?.name,
|
|
857
|
-
source,
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
// ===========================================================================
|
|
865
|
-
// Private Methods
|
|
866
|
-
// ===========================================================================
|
|
867
|
-
async resyncFromServer(originalError) {
|
|
868
|
-
this.logger.warn('[RoolSpace] Resyncing from server after sync failure');
|
|
869
|
-
try {
|
|
870
|
-
const { data } = await this.graphqlClient.getSpace(this._id);
|
|
871
|
-
// Check again after await - space might have been closed during fetch
|
|
872
|
-
if (this._closed)
|
|
873
|
-
return;
|
|
874
|
-
this._data = data;
|
|
875
|
-
// Clear history is now async but we don't need to wait for it during resync
|
|
876
|
-
// (it's a server-side cleanup that can happen in background)
|
|
877
|
-
this.clearHistory().catch((err) => {
|
|
878
|
-
this.logger.warn('[RoolSpace] Failed to clear history during resync:', err);
|
|
879
|
-
});
|
|
880
|
-
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
881
|
-
this.emit('reset', { source: 'system' });
|
|
882
|
-
}
|
|
883
|
-
catch (error) {
|
|
884
|
-
// If space was closed during fetch, don't log error - expected during teardown
|
|
885
|
-
if (this._closed)
|
|
886
|
-
return;
|
|
887
|
-
this.logger.error('[RoolSpace] Failed to resync from server:', error);
|
|
888
|
-
// Still emit syncError with the original error
|
|
889
|
-
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
890
|
-
}
|
|
135
|
+
async refresh() {
|
|
136
|
+
const { name, role, linkAccess, channels } = await this.graphqlClient.openSpace(this._id);
|
|
137
|
+
this._name = name;
|
|
138
|
+
this._role = role;
|
|
139
|
+
this._linkAccess = linkAccess;
|
|
140
|
+
this._channels = channels;
|
|
891
141
|
}
|
|
892
142
|
}
|
|
893
143
|
//# sourceMappingURL=space.js.map
|