@rool-dev/sdk 0.11.15 → 0.12.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 +23 -48
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -6
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +6 -11
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +4 -25
- 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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/space-session.d.ts +17 -252
- package/dist/space-session.d.ts.map +1 -1
- package/dist/space-session.js +53 -758
- package/dist/space-session.js.map +1 -1
- package/dist/space.d.ts +106 -88
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +524 -216
- package/dist/space.js.map +1 -1
- package/dist/types.d.ts +1 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/space-session.js
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import { EventEmitter } from './event-emitter.js';
|
|
2
|
-
import { WebDAVError } from './webdav.js';
|
|
3
|
-
import { isObjectPath, machinePath, machineUri } from './path.js';
|
|
4
|
-
// 6-character alphanumeric ID — used for object names, interactionIds, conversationIds, etc.
|
|
5
1
|
const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
6
|
-
const GET_OBJECTS_CHUNK_SIZE = 500;
|
|
7
2
|
export function generateEntityId() {
|
|
8
3
|
let result = '';
|
|
9
4
|
for (let i = 0; i < 6; i++) {
|
|
@@ -11,778 +6,78 @@ export function generateEntityId() {
|
|
|
11
6
|
}
|
|
12
7
|
return result;
|
|
13
8
|
}
|
|
14
|
-
/**
|
|
15
|
-
function
|
|
16
|
-
|
|
9
|
+
/** Find the most recently updated leaf in a conversation tree. */
|
|
10
|
+
export function defaultConversationLeaf(conversation) {
|
|
11
|
+
if (!conversation)
|
|
12
|
+
return undefined;
|
|
13
|
+
const parents = new Set();
|
|
14
|
+
for (const interaction of Object.values(conversation.interactions)) {
|
|
15
|
+
if (interaction.parentId)
|
|
16
|
+
parents.add(interaction.parentId);
|
|
17
|
+
}
|
|
18
|
+
let leaf;
|
|
19
|
+
for (const interaction of Object.values(conversation.interactions)) {
|
|
20
|
+
if (parents.has(interaction.id))
|
|
21
|
+
continue;
|
|
22
|
+
if (!leaf || interaction.timestamp > leaf.timestamp)
|
|
23
|
+
leaf = interaction;
|
|
24
|
+
}
|
|
25
|
+
return leaf?.id;
|
|
26
|
+
}
|
|
27
|
+
/** Return one conversation branch in root-to-leaf order. */
|
|
28
|
+
export function conversationBranch(conversation, leafId = defaultConversationLeaf(conversation)) {
|
|
29
|
+
if (!conversation || !leafId)
|
|
30
|
+
return [];
|
|
31
|
+
const branch = [];
|
|
17
32
|
let currentId = leafId;
|
|
18
33
|
while (currentId) {
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
34
|
+
const interaction = conversation.interactions[currentId];
|
|
35
|
+
if (!interaction)
|
|
21
36
|
break;
|
|
22
|
-
|
|
23
|
-
currentId =
|
|
24
|
-
}
|
|
25
|
-
return path.reverse();
|
|
26
|
-
}
|
|
27
|
-
/** Find the default leaf: the most recent interaction by timestamp that has no children. */
|
|
28
|
-
function findDefaultLeaf(interactions) {
|
|
29
|
-
if (!interactions)
|
|
30
|
-
return undefined;
|
|
31
|
-
const childSet = new Set();
|
|
32
|
-
for (const ix of Object.values(interactions)) {
|
|
33
|
-
if (ix.parentId)
|
|
34
|
-
childSet.add(ix.parentId);
|
|
35
|
-
}
|
|
36
|
-
// Leaves = interactions with no children, pick most recent
|
|
37
|
-
let best;
|
|
38
|
-
for (const ix of Object.values(interactions)) {
|
|
39
|
-
if (!childSet.has(ix.id)) {
|
|
40
|
-
if (!best || ix.timestamp > best.timestamp)
|
|
41
|
-
best = ix;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return best?.id;
|
|
45
|
-
}
|
|
46
|
-
function objectPath(input) {
|
|
47
|
-
const path = machinePath(input);
|
|
48
|
-
if (!isObjectPath(path)) {
|
|
49
|
-
throw new Error(`Object path must be /space/<collection>/<name>.json without dotfiles: ${input}`);
|
|
50
|
-
}
|
|
51
|
-
return path;
|
|
52
|
-
}
|
|
53
|
-
function collectionPath(name) {
|
|
54
|
-
return machinePath(`/space/${name}`);
|
|
55
|
-
}
|
|
56
|
-
function schemaPath(name) {
|
|
57
|
-
return `${collectionPath(name)}/.schema.json`;
|
|
58
|
-
}
|
|
59
|
-
function objectFromBody(path, body) {
|
|
60
|
-
return { path, body };
|
|
61
|
-
}
|
|
62
|
-
function jsonObject(value, label) {
|
|
63
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
64
|
-
throw new Error(`${label} must be a JSON object`);
|
|
65
|
-
}
|
|
66
|
-
return value;
|
|
67
|
-
}
|
|
68
|
-
function patchBody(current, patch) {
|
|
69
|
-
const next = { ...current };
|
|
70
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
71
|
-
if (value === null || value === undefined)
|
|
72
|
-
delete next[key];
|
|
73
|
-
else
|
|
74
|
-
next[key] = value;
|
|
75
|
-
}
|
|
76
|
-
return next;
|
|
77
|
-
}
|
|
78
|
-
function collectionDef(input, options) {
|
|
79
|
-
const base = Array.isArray(input)
|
|
80
|
-
? { fields: input }
|
|
81
|
-
: { fields: input.fields, schemaOrgType: input.schemaOrgType };
|
|
82
|
-
const schemaOrgType = options?.schemaOrgType ?? base.schemaOrgType;
|
|
83
|
-
return schemaOrgType ? { fields: base.fields, schemaOrgType } : { fields: base.fields };
|
|
84
|
-
}
|
|
85
|
-
function attachmentBody(file) {
|
|
86
|
-
if (isFile(file)) {
|
|
87
|
-
return {
|
|
88
|
-
filename: safeAttachmentFilename(file.name, file.type),
|
|
89
|
-
contentType: file.type || 'application/octet-stream',
|
|
90
|
-
body: file,
|
|
91
|
-
};
|
|
37
|
+
branch.push(interaction);
|
|
38
|
+
currentId = interaction.parentId;
|
|
92
39
|
}
|
|
93
|
-
|
|
94
|
-
const contentType = file.type || 'application/octet-stream';
|
|
95
|
-
return {
|
|
96
|
-
filename: safeAttachmentFilename('attachment', contentType),
|
|
97
|
-
contentType,
|
|
98
|
-
body: file,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
filename: safeAttachmentFilename(file.filename ?? 'attachment', file.contentType),
|
|
103
|
-
contentType: file.contentType,
|
|
104
|
-
body: base64Body(file.data),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
function isFile(value) {
|
|
108
|
-
return typeof File !== 'undefined' && value instanceof File;
|
|
40
|
+
return branch.reverse();
|
|
109
41
|
}
|
|
110
|
-
|
|
111
|
-
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
112
|
-
}
|
|
113
|
-
function safeAttachmentFilename(name, contentType) {
|
|
114
|
-
const fallback = `attachment.${extensionForContentType(contentType)}`;
|
|
115
|
-
const leaf = name.split(/[/\\]/).pop() || fallback;
|
|
116
|
-
const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
|
|
117
|
-
return cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
|
|
118
|
-
}
|
|
119
|
-
function extensionForContentType(contentType) {
|
|
120
|
-
if (contentType === 'image/png')
|
|
121
|
-
return 'png';
|
|
122
|
-
if (contentType === 'image/jpeg')
|
|
123
|
-
return 'jpg';
|
|
124
|
-
if (contentType === 'image/gif')
|
|
125
|
-
return 'gif';
|
|
126
|
-
if (contentType === 'image/webp')
|
|
127
|
-
return 'webp';
|
|
128
|
-
if (contentType === 'image/svg+xml')
|
|
129
|
-
return 'svg';
|
|
130
|
-
if (contentType === 'application/pdf')
|
|
131
|
-
return 'pdf';
|
|
132
|
-
if (contentType === 'text/markdown')
|
|
133
|
-
return 'md';
|
|
134
|
-
if (contentType === 'text/plain')
|
|
135
|
-
return 'txt';
|
|
136
|
-
if (contentType === 'text/csv')
|
|
137
|
-
return 'csv';
|
|
138
|
-
if (contentType === 'text/html')
|
|
139
|
-
return 'html';
|
|
140
|
-
if (contentType === 'application/json')
|
|
141
|
-
return 'json';
|
|
142
|
-
if (contentType === 'application/xml')
|
|
143
|
-
return 'xml';
|
|
144
|
-
return 'bin';
|
|
145
|
-
}
|
|
146
|
-
function base64Body(data) {
|
|
147
|
-
const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
|
|
148
|
-
if (typeof Buffer !== 'undefined') {
|
|
149
|
-
const buffer = Buffer.from(clean, 'base64');
|
|
150
|
-
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
151
|
-
}
|
|
152
|
-
const binary = atob(clean);
|
|
153
|
-
const bytes = new Uint8Array(binary.length);
|
|
154
|
-
for (let i = 0; i < binary.length; i++)
|
|
155
|
-
bytes[i] = binary.charCodeAt(i);
|
|
156
|
-
return bytes.buffer;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* A thin handle over a space's raw APIs (GraphQL, WebDAV, REST) plus the
|
|
160
|
-
* conversation roster the server pushes over SSE. It holds no schema, metadata,
|
|
161
|
-
* or conversation *content* state — schema and meta live in the filesystem
|
|
162
|
-
* (`/space/<collection>/.schema.json`, `/space/.meta.json`) and are read on
|
|
163
|
-
* demand via {@link readSchema} / {@link readMeta}; conversation contents live
|
|
164
|
-
* on {@link ConversationHandle} objects acquired on demand via
|
|
165
|
-
* {@link conversation}. The lightweight conversation meta list (ids + names +
|
|
166
|
-
* counts) is maintained here from openSpace + SSE. Reactive consumers (e.g. the
|
|
167
|
-
* Svelte wrapper) own any cached presentation of that state; this class just
|
|
168
|
-
* wraps the wire.
|
|
169
|
-
*
|
|
170
|
-
* Objects are addressed by machine path (`/space/.../*.json`). Conversation
|
|
171
|
-
* handles carry attribution headers for WebDAV writes. Object/file reactivity
|
|
172
|
-
* is exposed via `filesChanged` / `filesReset` plus WebDAV `syncCollection()`.
|
|
173
|
-
*/
|
|
174
|
-
export class SpaceOperations extends EventEmitter {
|
|
175
|
-
_id;
|
|
176
|
-
_name;
|
|
177
|
-
_role;
|
|
178
|
-
_userId;
|
|
179
|
-
_closed = false;
|
|
180
|
-
_graphqlClient;
|
|
181
|
-
_restClient;
|
|
182
|
-
_webdav;
|
|
183
|
-
_onCloseCallback;
|
|
184
|
-
_logger;
|
|
185
|
-
// Conversation roster — lightweight meta, no interaction bodies. Maintained
|
|
186
|
-
// from openSpace + SSE conversation_updated events.
|
|
187
|
-
_conversationMeta;
|
|
188
|
-
constructor(config) {
|
|
189
|
-
super();
|
|
190
|
-
this._id = config.id;
|
|
191
|
-
this._name = config.name;
|
|
192
|
-
this._role = config.role;
|
|
193
|
-
this._userId = config.userId;
|
|
194
|
-
this._emitterLogger = config.logger;
|
|
195
|
-
this._graphqlClient = config.graphqlClient;
|
|
196
|
-
this._restClient = config.restClient;
|
|
197
|
-
this._webdav = config.webdav;
|
|
198
|
-
this._logger = config.logger;
|
|
199
|
-
this._onCloseCallback = config.onClose;
|
|
200
|
-
this._conversationMeta = config.conversationMeta;
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Handle an event from the shared space subscription.
|
|
204
|
-
* Called by the client's event router.
|
|
205
|
-
* @internal
|
|
206
|
-
*/
|
|
207
|
-
_handleEvent(event) {
|
|
208
|
-
this.applySpaceContentEvent(event);
|
|
209
|
-
}
|
|
210
|
-
get id() {
|
|
211
|
-
return this._id;
|
|
212
|
-
}
|
|
213
|
-
get name() {
|
|
214
|
-
return this._name;
|
|
215
|
-
}
|
|
216
|
-
get role() {
|
|
217
|
-
return this._role;
|
|
218
|
-
}
|
|
219
|
-
/** Current user's ID (for identifying own interactions) */
|
|
220
|
-
get userId() {
|
|
221
|
-
return this._userId;
|
|
222
|
-
}
|
|
223
|
-
get isReadOnly() {
|
|
224
|
-
return this._role === 'viewer';
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Lightweight conversation roster (no interaction bodies). Maintained from
|
|
228
|
-
* openSpace + SSE; reactive consumers mirror this list.
|
|
229
|
-
*/
|
|
230
|
-
getConversations() {
|
|
231
|
-
return this._conversationMeta;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Delete a conversation. The server confirms via SSE (`conversation_updated`
|
|
235
|
-
* with a null conversation), which updates the roster and any live handle.
|
|
236
|
-
*/
|
|
237
|
-
async deleteConversation(conversationId) {
|
|
238
|
-
await this._graphqlClient.deleteConversation(this._id, conversationId);
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Create a conversation under an agent. The server mints the id (unique
|
|
242
|
-
* across the space's agents) and creates the folder with the requested
|
|
243
|
-
* visibility; prompt the returned id to start talking.
|
|
244
|
-
*/
|
|
245
|
-
async createConversation(agent, visibility) {
|
|
246
|
-
return this._graphqlClient.createConversation(this._id, agent, visibility);
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* The space's agents (folder names under /agents). Always includes the stock
|
|
250
|
-
* agent `rool`.
|
|
251
|
-
*/
|
|
252
|
-
async listAgents() {
|
|
253
|
-
return this._graphqlClient.listAgents(this._id);
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Delete a custom agent and all its conversations. The stock agent `rool`
|
|
257
|
-
* cannot be deleted.
|
|
258
|
-
*/
|
|
259
|
-
async deleteAgent(agent) {
|
|
260
|
-
await this._graphqlClient.deleteAgent(this._id, agent);
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Get a handle for a conversation. The handle holds its own interaction tree
|
|
264
|
-
* + cursor; call {@link ConversationHandle.load} to fetch an existing
|
|
265
|
-
* conversation's contents, or just {@link ConversationHandle.prompt} to start
|
|
266
|
-
* a new one (SSE fills the tree). Consumers feed SSE updates into the handle
|
|
267
|
-
* via {@link ConversationHandle.applyUpdate}.
|
|
268
|
-
*/
|
|
269
|
-
conversation(conversationId) {
|
|
270
|
-
return new ConversationHandle(this, conversationId);
|
|
271
|
-
}
|
|
272
|
-
/** @internal — fetch a conversation's full contents from the server. */
|
|
273
|
-
async _fetchConversationImpl(conversationId) {
|
|
274
|
-
return this._graphqlClient.getConversation(this._id, conversationId);
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Close this space session and clean up resources.
|
|
278
|
-
*/
|
|
279
|
-
close() {
|
|
280
|
-
this._closed = true;
|
|
281
|
-
this._onCloseCallback();
|
|
282
|
-
this.removeAllListeners();
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* @deprecated Checkpoints are now managed by the server; this is a no-op.
|
|
286
|
-
*/
|
|
287
|
-
async checkpoint(_label = 'Change') {
|
|
288
|
-
this._logger.warn('[RoolSpace] checkpoint() is a no-op: checkpoints are now managed by the server.');
|
|
289
|
-
return '';
|
|
290
|
-
}
|
|
291
|
-
/** Check if undo is available for this space. */
|
|
292
|
-
async canUndo() {
|
|
293
|
-
const status = await this._graphqlClient.checkpointStatus(this._id);
|
|
294
|
-
return status.canUndo;
|
|
295
|
-
}
|
|
296
|
-
/** Check if redo is available for this space. */
|
|
297
|
-
async canRedo() {
|
|
298
|
-
const status = await this._graphqlClient.checkpointStatus(this._id);
|
|
299
|
-
return status.canRedo;
|
|
300
|
-
}
|
|
301
|
-
/** Restore the space to the most recent checkpoint. */
|
|
302
|
-
async undo() {
|
|
303
|
-
const result = await this._graphqlClient.undo(this._id);
|
|
304
|
-
return result.success;
|
|
305
|
-
}
|
|
306
|
-
/** Reapply the most recently undone checkpoint. */
|
|
307
|
-
async redo() {
|
|
308
|
-
const result = await this._graphqlClient.redo(this._id);
|
|
309
|
-
return result.success;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* @deprecated Checkpoint history is now managed by the server; this is a no-op.
|
|
313
|
-
*/
|
|
314
|
-
async clearHistory() {
|
|
315
|
-
this._logger.warn('[RoolSpace] clearHistory() is a no-op: checkpoint history is now managed by the server.');
|
|
316
|
-
}
|
|
317
|
-
davHeaders(interactionId) {
|
|
318
|
-
const headers = new Headers();
|
|
319
|
-
if (interactionId)
|
|
320
|
-
headers.set('X-Rool-Interaction-Id', interactionId);
|
|
321
|
-
return headers;
|
|
322
|
-
}
|
|
323
|
-
async readObject(path) {
|
|
324
|
-
const canonical = objectPath(path);
|
|
325
|
-
try {
|
|
326
|
-
const response = await this._webdav.get(canonical);
|
|
327
|
-
const body = jsonObject(await response.json(), `Object ${canonical}`);
|
|
328
|
-
return { object: objectFromBody(canonical, body), etag: response.headers.get('ETag') };
|
|
329
|
-
}
|
|
330
|
-
catch (error) {
|
|
331
|
-
if (error instanceof WebDAVError && error.status === 404)
|
|
332
|
-
return undefined;
|
|
333
|
-
if (error instanceof SyntaxError)
|
|
334
|
-
throw new Error(`Object ${canonical} did not contain valid JSON`);
|
|
335
|
-
throw error;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
/** Get an object JSON file by machine path. Fetches from the server on each call. */
|
|
339
|
-
async getObject(path) {
|
|
340
|
-
return (await this.readObject(path))?.object;
|
|
341
|
-
}
|
|
342
|
-
/** Get object JSON files by machine path in bulk. Duplicate paths are fetched once. */
|
|
343
|
-
async getObjects(paths) {
|
|
344
|
-
const canonical = [];
|
|
345
|
-
const seen = new Set();
|
|
346
|
-
for (const path of paths) {
|
|
347
|
-
const normalized = objectPath(path);
|
|
348
|
-
if (seen.has(normalized))
|
|
349
|
-
continue;
|
|
350
|
-
seen.add(normalized);
|
|
351
|
-
canonical.push(normalized);
|
|
352
|
-
}
|
|
353
|
-
const result = { objects: [], missing: [] };
|
|
354
|
-
for (let i = 0; i < canonical.length; i += GET_OBJECTS_CHUNK_SIZE) {
|
|
355
|
-
const chunk = canonical.slice(i, i + GET_OBJECTS_CHUNK_SIZE);
|
|
356
|
-
const partial = await this._restClient.getObjects(this._id, chunk);
|
|
357
|
-
result.objects.push(...partial.objects);
|
|
358
|
-
result.missing.push(...partial.missing);
|
|
359
|
-
}
|
|
360
|
-
return result;
|
|
361
|
-
}
|
|
362
|
-
/** Create or replace an object JSON file. */
|
|
363
|
-
async putObject(path, body) {
|
|
364
|
-
const canonical = objectPath(path);
|
|
365
|
-
const optimistic = objectFromBody(canonical, body);
|
|
366
|
-
try {
|
|
367
|
-
const interactionId = generateEntityId();
|
|
368
|
-
await this._webdav.put(canonical, JSON.stringify(body), {
|
|
369
|
-
contentType: 'application/json',
|
|
370
|
-
headers: this.davHeaders(interactionId),
|
|
371
|
-
});
|
|
372
|
-
const fresh = await this.getObject(canonical) ?? optimistic;
|
|
373
|
-
return { object: fresh, message: `Put ${canonical}` };
|
|
374
|
-
}
|
|
375
|
-
catch (error) {
|
|
376
|
-
this._logger.error('[RoolSpace] Failed to put object:', error);
|
|
377
|
-
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
378
|
-
throw error;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
/** Patch an existing object JSON file. */
|
|
382
|
-
async patchObject(path, options) {
|
|
383
|
-
const canonical = objectPath(path);
|
|
384
|
-
const data = options.data ?? {};
|
|
385
|
-
const current = await this.readObject(canonical);
|
|
386
|
-
if (!current)
|
|
387
|
-
throw new Error(`Object ${canonical} not found`);
|
|
388
|
-
const body = patchBody(current.object.body, data);
|
|
389
|
-
const optimistic = objectFromBody(canonical, body);
|
|
390
|
-
try {
|
|
391
|
-
const interactionId = generateEntityId();
|
|
392
|
-
await this._webdav.put(canonical, JSON.stringify(body), {
|
|
393
|
-
contentType: 'application/json',
|
|
394
|
-
ifMatch: current.etag ?? undefined,
|
|
395
|
-
headers: this.davHeaders(interactionId),
|
|
396
|
-
});
|
|
397
|
-
const fresh = await this.getObject(canonical) ?? optimistic;
|
|
398
|
-
return { object: fresh, message: `Patched ${canonical}` };
|
|
399
|
-
}
|
|
400
|
-
catch (error) {
|
|
401
|
-
this._logger.error('[RoolSpace] Failed to patch object:', error);
|
|
402
|
-
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
403
|
-
throw error;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
/** Move (rename/relocate) an object. */
|
|
407
|
-
async moveObject(from, to, options) {
|
|
408
|
-
const fromPath = objectPath(from);
|
|
409
|
-
const toPath = objectPath(to);
|
|
410
|
-
const optimistic = objectFromBody(toPath, options?.body ?? {});
|
|
411
|
-
try {
|
|
412
|
-
const interactionId = generateEntityId();
|
|
413
|
-
await this._webdav.move(fromPath, toPath, {
|
|
414
|
-
headers: this.davHeaders(interactionId),
|
|
415
|
-
});
|
|
416
|
-
if (options?.body) {
|
|
417
|
-
await this._webdav.put(toPath, JSON.stringify(options.body), {
|
|
418
|
-
contentType: 'application/json',
|
|
419
|
-
headers: this.davHeaders(interactionId),
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
const fresh = await this.getObject(toPath) ?? optimistic;
|
|
423
|
-
return { object: fresh, message: `Moved ${fromPath} to ${toPath}` };
|
|
424
|
-
}
|
|
425
|
-
catch (error) {
|
|
426
|
-
this._logger.error('[RoolSpace] Failed to move object:', error);
|
|
427
|
-
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
428
|
-
throw error;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
/** Delete object JSON files by path. */
|
|
432
|
-
async deleteObjects(paths) {
|
|
433
|
-
if (paths.length === 0)
|
|
434
|
-
return;
|
|
435
|
-
const canonical = paths.map(objectPath);
|
|
436
|
-
try {
|
|
437
|
-
const interactionId = generateEntityId();
|
|
438
|
-
for (const path of canonical) {
|
|
439
|
-
await this._webdav.delete(path, {
|
|
440
|
-
headers: this.davHeaders(interactionId),
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
catch (error) {
|
|
445
|
-
this._logger.error('[RoolSpace] Failed to delete paths:', error);
|
|
446
|
-
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
447
|
-
throw error;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Read space metadata from `/space/.meta.json`. Returns `{}` when the space has
|
|
452
|
-
* no metadata file yet. Stateless — callers (e.g. a reactive wrapper) cache and
|
|
453
|
-
* re-fetch this on their own schedule, typically when a file-tree sync reports
|
|
454
|
-
* the node changed.
|
|
455
|
-
*/
|
|
456
|
-
async readMeta() {
|
|
457
|
-
try {
|
|
458
|
-
const response = await this._webdav.get('/space/.meta.json');
|
|
459
|
-
return jsonObject(await response.json(), 'space meta');
|
|
460
|
-
}
|
|
461
|
-
catch (error) {
|
|
462
|
-
if (error instanceof WebDAVError && error.status === 404)
|
|
463
|
-
return {};
|
|
464
|
-
throw error;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Write the full metadata blob to `/space/.meta.json`, attributed to a
|
|
469
|
-
* conversation. Callers compose the blob (e.g. read-merge-write) — this does no
|
|
470
|
-
* merging.
|
|
471
|
-
*/
|
|
472
|
-
async writeMeta(meta) {
|
|
473
|
-
await this._webdav.put('/space/.meta.json', JSON.stringify(meta), {
|
|
474
|
-
contentType: 'application/json',
|
|
475
|
-
headers: this.davHeaders(generateEntityId()),
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Read the collection schema: one `/space/<name>/.schema.json` per collection
|
|
480
|
-
* directory under `/space`. Returns `{}` for a space with no collections.
|
|
481
|
-
* Stateless — reactive callers re-fetch when a `.schema.json` node changes.
|
|
482
|
-
*/
|
|
483
|
-
async readSchema() {
|
|
484
|
-
const listing = await this._webdav.propfind('/space', { depth: '1', props: ['resourcetype'] });
|
|
485
|
-
const collections = listing.responses
|
|
486
|
-
.filter((r) => r.isCollection && r.path !== '/space')
|
|
487
|
-
.map((r) => r.path.split('/').pop());
|
|
488
|
-
const entries = await Promise.all(collections.map(async (name) => {
|
|
489
|
-
try {
|
|
490
|
-
const response = await this._webdav.get(`/space/${name}/.schema.json`);
|
|
491
|
-
return [name, jsonObject(await response.json(), `schema ${name}`)];
|
|
492
|
-
}
|
|
493
|
-
catch (error) {
|
|
494
|
-
if (error instanceof WebDAVError && error.status === 404)
|
|
495
|
-
return null;
|
|
496
|
-
throw error;
|
|
497
|
-
}
|
|
498
|
-
}));
|
|
499
|
-
const schema = {};
|
|
500
|
-
for (const entry of entries)
|
|
501
|
-
if (entry)
|
|
502
|
-
schema[entry[0]] = entry[1];
|
|
503
|
-
return schema;
|
|
504
|
-
}
|
|
505
|
-
/** Create a new collection (MKCOL + schema JSON). */
|
|
506
|
-
async createCollection(name, fields, options) {
|
|
507
|
-
const def = collectionDef(fields, options);
|
|
508
|
-
await this._webdav.mkcol(collectionPath(name), { headers: this.davHeaders(generateEntityId()) });
|
|
509
|
-
await this._webdav.put(schemaPath(name), JSON.stringify(def), {
|
|
510
|
-
contentType: 'application/json',
|
|
511
|
-
headers: this.davHeaders(generateEntityId()),
|
|
512
|
-
});
|
|
513
|
-
return def;
|
|
514
|
-
}
|
|
515
|
-
/** Alter an existing collection's schema JSON. */
|
|
516
|
-
async alterCollection(name, fields, options) {
|
|
517
|
-
const def = collectionDef(fields, options);
|
|
518
|
-
await this._webdav.put(schemaPath(name), JSON.stringify(def), {
|
|
519
|
-
contentType: 'application/json',
|
|
520
|
-
headers: this.davHeaders(generateEntityId()),
|
|
521
|
-
});
|
|
522
|
-
return def;
|
|
523
|
-
}
|
|
524
|
-
/** Drop a collection (DELETE). */
|
|
525
|
-
async dropCollection(name) {
|
|
526
|
-
await this._webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(generateEntityId()) });
|
|
527
|
-
}
|
|
528
|
-
/** @internal */
|
|
529
|
-
async _setSystemInstructionImpl(instruction, conversationId) {
|
|
530
|
-
await this._graphqlClient.updateConversation(this._id, conversationId, { systemInstruction: instruction });
|
|
531
|
-
}
|
|
532
|
-
/** @internal */
|
|
533
|
-
async _renameConversationImpl(name, conversationId) {
|
|
534
|
-
await this._graphqlClient.updateConversation(this._id, conversationId, { name });
|
|
535
|
-
}
|
|
536
|
-
/** @internal */
|
|
537
|
-
async _promptImpl(prompt, options, conversationId) {
|
|
538
|
-
const { attachments, signal, interactionId = generateEntityId(), parentInteractionId = null, ...rest } = options ?? {};
|
|
539
|
-
let attachmentRefs;
|
|
540
|
-
if (attachments?.length) {
|
|
541
|
-
attachmentRefs = await Promise.all(attachments.map(async (attachment) => {
|
|
542
|
-
const path = typeof attachment === 'string' ? machinePath(attachment) : await this.uploadAttachment(attachment, conversationId);
|
|
543
|
-
return machineUri(path);
|
|
544
|
-
}));
|
|
545
|
-
}
|
|
546
|
-
let onAbort;
|
|
547
|
-
if (signal) {
|
|
548
|
-
if (signal.aborted) {
|
|
549
|
-
this.stopConversation(conversationId).catch(() => { });
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
onAbort = () => {
|
|
553
|
-
this.stopConversation(conversationId).catch(() => { });
|
|
554
|
-
};
|
|
555
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
let result;
|
|
559
|
-
try {
|
|
560
|
-
result = await this._graphqlClient.prompt(this._id, prompt, conversationId, {
|
|
561
|
-
...rest,
|
|
562
|
-
attachmentRefs,
|
|
563
|
-
interactionId,
|
|
564
|
-
parentInteractionId,
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
finally {
|
|
568
|
-
if (onAbort)
|
|
569
|
-
signal.removeEventListener('abort', onAbort);
|
|
570
|
-
}
|
|
571
|
-
const objects = [];
|
|
572
|
-
const fetched = await Promise.all(result.modifiedObjectPaths.map((path) => this.getObject(path)));
|
|
573
|
-
for (const object of fetched) {
|
|
574
|
-
if (object)
|
|
575
|
-
objects.push(object);
|
|
576
|
-
}
|
|
577
|
-
return {
|
|
578
|
-
message: result.message,
|
|
579
|
-
objects,
|
|
580
|
-
creditsUsed: result.creditsUsed,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Request that the server stop a specific in-flight interaction by ID.
|
|
585
|
-
*
|
|
586
|
-
* @deprecated Reaches only a prompt the server is still awaiting; use
|
|
587
|
-
* {@link stopConversation}, which stops the run regardless of how or where
|
|
588
|
-
* it was started.
|
|
589
|
-
*/
|
|
590
|
-
async stopInteraction(interactionId) {
|
|
591
|
-
return this._graphqlClient.stopInteraction(this._id, interactionId);
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Stop whatever is running in a conversation. A conversation processes one
|
|
595
|
-
* run at a time, so no interaction handle is needed.
|
|
596
|
-
*
|
|
597
|
-
* Returns whether anything was actually running.
|
|
598
|
-
*/
|
|
599
|
-
async stopConversation(conversationId) {
|
|
600
|
-
return this._graphqlClient.stopConversation(this._id, conversationId);
|
|
601
|
-
}
|
|
602
|
-
/**
|
|
603
|
-
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
604
|
-
*/
|
|
605
|
-
async fetch(url, init) {
|
|
606
|
-
return this._restClient.proxyFetch(this._id, url, init);
|
|
607
|
-
}
|
|
608
|
-
async uploadAttachment(file, conversationId) {
|
|
609
|
-
const attachment = attachmentBody(file);
|
|
610
|
-
const path = `/rool-drive/attachments/${conversationId}/${attachment.filename}`;
|
|
611
|
-
// createParents avoids racing MKCOLs when multiple attachments upload concurrently.
|
|
612
|
-
await this._webdav.put(path, attachment.body, {
|
|
613
|
-
contentType: attachment.contentType,
|
|
614
|
-
createParents: true,
|
|
615
|
-
});
|
|
616
|
-
return path;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* Handle a space event from the subscription.
|
|
620
|
-
* @internal
|
|
621
|
-
*/
|
|
622
|
-
applySpaceContentEvent(event) {
|
|
623
|
-
if (this._closed)
|
|
624
|
-
return;
|
|
625
|
-
if (event.type !== 'conversation_updated' || !event.conversationId)
|
|
626
|
-
return;
|
|
627
|
-
const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
628
|
-
const conversation = event.conversation ?? null;
|
|
629
|
-
this._updateConversationMeta(event.conversationId, conversation);
|
|
630
|
-
this.emit('conversationUpdated', {
|
|
631
|
-
conversationId: event.conversationId,
|
|
632
|
-
conversation,
|
|
633
|
-
source: changeSource,
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
/** Maintain the conversation meta list from an SSE update. */
|
|
637
|
-
_updateConversationMeta(id, conversation) {
|
|
638
|
-
if (conversation === null) {
|
|
639
|
-
this._conversationMeta = this._conversationMeta.filter((m) => m.id !== id);
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
const interactionCount = conversation.interactions ? Object.keys(conversation.interactions).length : 0;
|
|
643
|
-
const entry = {
|
|
644
|
-
id,
|
|
645
|
-
agent: conversation.agent ?? 'rool',
|
|
646
|
-
visibility: conversation.visibility ?? 'shared',
|
|
647
|
-
name: conversation.name ?? null,
|
|
648
|
-
systemInstruction: conversation.systemInstruction ?? null,
|
|
649
|
-
createdAt: conversation.createdAt,
|
|
650
|
-
createdBy: conversation.createdBy,
|
|
651
|
-
interactionCount,
|
|
652
|
-
updatedAt: Date.now(),
|
|
653
|
-
};
|
|
654
|
-
const idx = this._conversationMeta.findIndex((m) => m.id === id);
|
|
655
|
-
if (idx >= 0) {
|
|
656
|
-
this._conversationMeta = [
|
|
657
|
-
...this._conversationMeta.slice(0, idx),
|
|
658
|
-
entry,
|
|
659
|
-
...this._conversationMeta.slice(idx + 1),
|
|
660
|
-
];
|
|
661
|
-
}
|
|
662
|
-
else {
|
|
663
|
-
this._conversationMeta = [entry, ...this._conversationMeta];
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* A stateful handle for a single conversation. Holds the interaction tree and a
|
|
669
|
-
* client-side branch cursor (active leaf). Acquired on demand via
|
|
670
|
-
* {@link SpaceOperations.conversation}; a fresh handle each call — the SDK
|
|
671
|
-
* holds no handle map. The handle starts unloaded; call {@link load} to fetch
|
|
672
|
-
* an existing conversation's contents, or just {@link prompt} to start a new
|
|
673
|
-
* one. Feed SSE updates via {@link applyUpdate}.
|
|
674
|
-
*/
|
|
42
|
+
/** An imperative API scoped to one conversation. It retains no conversation state. */
|
|
675
43
|
export class ConversationHandle {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
_data = null;
|
|
679
|
-
_activeLeaf;
|
|
680
|
-
/** @internal */
|
|
44
|
+
space;
|
|
45
|
+
conversationId;
|
|
681
46
|
constructor(space, conversationId) {
|
|
682
|
-
this.
|
|
683
|
-
this.
|
|
684
|
-
}
|
|
685
|
-
/** The conversation ID this handle is scoped to. */
|
|
686
|
-
get conversationId() { return this._conversationId; }
|
|
687
|
-
/** Whether the conversation contents have been loaded. */
|
|
688
|
-
get loaded() { return this._data !== null; }
|
|
689
|
-
/** Fetch the full conversation from the server. Skips the overwrite if SSE
|
|
690
|
-
* already filled the tree during the fetch (SSE is the real-time source of
|
|
691
|
-
* truth). Pass `true` to force a reload — used after a reconnect `reset`,
|
|
692
|
-
* where we may have missed SSE updates while disconnected. */
|
|
693
|
-
async load(force = false) {
|
|
694
|
-
const data = await this._space._fetchConversationImpl(this._conversationId);
|
|
695
|
-
if (!force && this._data)
|
|
696
|
-
return;
|
|
697
|
-
this._data = data;
|
|
698
|
-
this._activeLeaf = this._data ? findDefaultLeaf(this._data.interactions) : undefined;
|
|
699
|
-
}
|
|
700
|
-
/** Apply an SSE conversation update — updates the tree + cursor. Pass `null`
|
|
701
|
-
* for a deletion. */
|
|
702
|
-
applyUpdate(conversation) {
|
|
703
|
-
if (conversation === null) {
|
|
704
|
-
this._data = null;
|
|
705
|
-
this._activeLeaf = undefined;
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
// Advance the cursor to a new child of the current leaf, if one appeared.
|
|
709
|
-
const currentLeaf = this._activeLeaf;
|
|
710
|
-
if (currentLeaf) {
|
|
711
|
-
for (const ix of Object.values(conversation.interactions)) {
|
|
712
|
-
if (ix.parentId === currentLeaf && ix.id !== currentLeaf) {
|
|
713
|
-
this._activeLeaf = ix.id;
|
|
714
|
-
break;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
this._data = conversation;
|
|
719
|
-
if (this._activeLeaf === undefined) {
|
|
720
|
-
this._activeLeaf = findDefaultLeaf(conversation.interactions);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
/** Get the active branch as a flat array (root → leaf). Empty until loaded. */
|
|
724
|
-
getInteractions() {
|
|
725
|
-
if (!this._data)
|
|
726
|
-
return [];
|
|
727
|
-
const leaf = this._activeLeaf ?? findDefaultLeaf(this._data.interactions);
|
|
728
|
-
if (!leaf)
|
|
729
|
-
return [];
|
|
730
|
-
return walkBranch(this._data.interactions, leaf);
|
|
731
|
-
}
|
|
732
|
-
/** Get the full interaction tree as a record. Empty until loaded. */
|
|
733
|
-
getTree() {
|
|
734
|
-
if (!this._data)
|
|
735
|
-
return {};
|
|
736
|
-
return this._data.interactions;
|
|
47
|
+
this.space = space;
|
|
48
|
+
this.conversationId = conversationId;
|
|
737
49
|
}
|
|
738
|
-
/**
|
|
739
|
-
get
|
|
740
|
-
|
|
741
|
-
return this._activeLeaf;
|
|
742
|
-
if (!this._data)
|
|
743
|
-
return undefined;
|
|
744
|
-
return findDefaultLeaf(this._data.interactions);
|
|
50
|
+
/** Fetch the current conversation contents. */
|
|
51
|
+
async get() {
|
|
52
|
+
return this.space.getConversation(this.conversationId);
|
|
745
53
|
}
|
|
746
|
-
/**
|
|
747
|
-
setActiveLeaf(interactionId) {
|
|
748
|
-
if (!this._data || !this._data.interactions[interactionId]) {
|
|
749
|
-
throw new Error(`Interaction "${interactionId}" not found in conversation "${this._conversationId}"`);
|
|
750
|
-
}
|
|
751
|
-
this._activeLeaf = interactionId;
|
|
752
|
-
}
|
|
753
|
-
/** Get the system instruction for this conversation. */
|
|
754
|
-
getSystemInstruction() {
|
|
755
|
-
return this._data?.systemInstruction;
|
|
756
|
-
}
|
|
757
|
-
/** Set the system instruction for this conversation. Pass null to clear. */
|
|
54
|
+
/** Set the conversation system instruction. Pass null to clear it. */
|
|
758
55
|
async setSystemInstruction(instruction) {
|
|
759
|
-
|
|
56
|
+
await this.space._updateConversation(this.conversationId, { systemInstruction: instruction });
|
|
760
57
|
}
|
|
761
|
-
/** Rename this conversation. */
|
|
762
58
|
async rename(name) {
|
|
763
|
-
|
|
59
|
+
await this.space._updateConversation(this.conversationId, { name });
|
|
764
60
|
}
|
|
765
|
-
/** Delete this conversation. */
|
|
766
61
|
async delete() {
|
|
767
|
-
|
|
62
|
+
await this.space.deleteConversation(this.conversationId);
|
|
768
63
|
}
|
|
769
|
-
/**
|
|
770
|
-
*
|
|
64
|
+
/**
|
|
65
|
+
* Prompt this conversation. With no explicit parent, continue from the
|
|
66
|
+
* current default leaf without retaining a client-side cursor.
|
|
67
|
+
*/
|
|
771
68
|
async prompt(text, options) {
|
|
772
69
|
const interactionId = options?.interactionId ?? generateEntityId();
|
|
773
|
-
const parentInteractionId = options?.parentInteractionId
|
|
774
|
-
?
|
|
775
|
-
:
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
70
|
+
const parentInteractionId = options?.parentInteractionId === undefined
|
|
71
|
+
? defaultConversationLeaf(await this.get()) ?? null
|
|
72
|
+
: options.parentInteractionId;
|
|
73
|
+
return this.space._prompt(text, this.conversationId, {
|
|
74
|
+
...options,
|
|
75
|
+
interactionId,
|
|
76
|
+
parentInteractionId,
|
|
77
|
+
});
|
|
779
78
|
}
|
|
780
|
-
/**
|
|
781
|
-
* Stop this conversation's running work, if any. No-op returning `false`
|
|
782
|
-
* when nothing is running. See {@link RoolSpace.stopConversation}.
|
|
783
|
-
*/
|
|
784
79
|
async stop() {
|
|
785
|
-
return this.
|
|
80
|
+
return this.space.stopConversation(this.conversationId);
|
|
786
81
|
}
|
|
787
82
|
}
|
|
788
83
|
//# sourceMappingURL=space-session.js.map
|