@rool-dev/sdk 0.11.8 → 0.11.9-dev.38b242c

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.
@@ -26,7 +26,7 @@ function walkBranch(interactions, leafId) {
26
26
  }
27
27
  /** Find the default leaf: the most recent interaction by timestamp that has no children. */
28
28
  function findDefaultLeaf(interactions) {
29
- if (!interactions || Array.isArray(interactions))
29
+ if (!interactions)
30
30
  return undefined;
31
31
  const childSet = new Set();
32
32
  for (const ix of Object.values(interactions)) {
@@ -156,13 +156,20 @@ function base64Body(data) {
156
156
  return bytes.buffer;
157
157
  }
158
158
  /**
159
- * Operational handle for a space.
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.
160
169
  *
161
- * Objects are addressed by machine path (`/space/.../*.json`). Conversation handles
162
- * control attribution and AI history. Only schema, metadata,
163
- * object stats, and conversation history are cached locally. Object bodies are
164
- * fetched on demand. Object/file reactivity is exposed at the space level via
165
- * WebDAV sync notifications.
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()`.
166
173
  */
167
174
  export class SpaceOperations extends EventEmitter {
168
175
  _id;
@@ -175,13 +182,9 @@ export class SpaceOperations extends EventEmitter {
175
182
  _webdav;
176
183
  _onCloseCallback;
177
184
  _logger;
178
- // Local cache for bounded data (schema, metadata, conversations, object stats)
179
- _meta;
180
- _schema;
181
- _conversations;
182
- _objectStats;
183
- // Active leaf per conversation (client-side tree cursor)
184
- _activeLeaves = new Map();
185
+ // Conversation roster lightweight meta, no interaction bodies. Maintained
186
+ // from openSpace + SSE conversation_updated events.
187
+ _conversationMeta;
185
188
  constructor(config) {
186
189
  super();
187
190
  this._id = config.id;
@@ -194,11 +197,7 @@ export class SpaceOperations extends EventEmitter {
194
197
  this._webdav = config.webdav;
195
198
  this._logger = config.logger;
196
199
  this._onCloseCallback = config.onClose;
197
- // Initialize local cache from server data
198
- this._meta = config.meta;
199
- this._schema = config.schema;
200
- this._conversations = config.conversations;
201
- this._objectStats = new Map(Object.entries(config.objectStats));
200
+ this._conversationMeta = config.conversationMeta;
202
201
  }
203
202
  /**
204
203
  * Handle an event from the shared space subscription.
@@ -208,21 +207,6 @@ export class SpaceOperations extends EventEmitter {
208
207
  _handleEvent(event) {
209
208
  this.applySpaceContentEvent(event);
210
209
  }
211
- /**
212
- * Apply resync data after reconnection. Called by the client, which
213
- * fetches space data once and applies it.
214
- * @internal
215
- */
216
- _applyResyncData(data) {
217
- if (this._closed)
218
- return;
219
- this._meta = data.meta;
220
- this._schema = data.schema;
221
- this._objectStats = new Map(Object.entries(data.objectStats));
222
- this._conversations = data.conversations;
223
- this._activeLeaves.clear();
224
- this.emit('reset', { source: 'system' });
225
- }
226
210
  get id() {
227
211
  return this._id;
228
212
  }
@@ -239,77 +223,34 @@ export class SpaceOperations extends EventEmitter {
239
223
  get isReadOnly() {
240
224
  return this._role === 'viewer';
241
225
  }
242
- /** @internal */
243
- _getInteractionsImpl(conversationId) {
244
- const interactions = this._conversations[conversationId]?.interactions;
245
- if (!interactions)
246
- return [];
247
- // Handle legacy array format
248
- if (Array.isArray(interactions))
249
- return interactions;
250
- const leafId = this._getActiveLeafImpl(conversationId);
251
- if (!leafId)
252
- return [];
253
- return walkBranch(interactions, leafId);
254
- }
255
- /** @internal */
256
- _getTreeImpl(conversationId) {
257
- const interactions = this._conversations[conversationId]?.interactions;
258
- if (!interactions || Array.isArray(interactions))
259
- return {};
260
- return interactions;
261
- }
262
- /** @internal */
263
- _getActiveLeafImpl(conversationId) {
264
- return this._activeLeaves.get(conversationId) ?? findDefaultLeaf(this._conversations[conversationId]?.interactions);
265
- }
266
- /** @internal */
267
- _setActiveLeafImpl(interactionId, conversationId) {
268
- const interactions = this._conversations[conversationId]?.interactions;
269
- if (!interactions || Array.isArray(interactions) || !interactions[interactionId]) {
270
- throw new Error(`Interaction "${interactionId}" not found in conversation "${conversationId}"`);
271
- }
272
- this._activeLeaves.set(conversationId, interactionId);
273
- this.emit('conversationUpdated', {
274
- conversationId,
275
- source: 'local_user',
276
- });
277
- }
278
226
  /**
279
- * Get all conversations in this space.
280
- * Returns summary info (no full interaction data) for each conversation.
227
+ * Lightweight conversation roster (no interaction bodies). Maintained from
228
+ * openSpace + SSE; reactive consumers mirror this list.
281
229
  */
282
230
  getConversations() {
283
- return Object.entries(this._conversations).map(([id, conv]) => ({
284
- id,
285
- name: conv.name ?? null,
286
- systemInstruction: conv.systemInstruction ?? null,
287
- createdAt: conv.createdAt,
288
- createdBy: conv.createdBy,
289
- interactionCount: conv.interactions ? Object.keys(conv.interactions).length : 0,
290
- }));
231
+ return this._conversationMeta;
291
232
  }
292
233
  /**
293
- * Delete a conversation from this space.
234
+ * Delete a conversation. The server confirms via SSE (`conversation_updated`
235
+ * with a null conversation), which updates the roster and any live handle.
294
236
  */
295
237
  async deleteConversation(conversationId) {
296
238
  await this._graphqlClient.deleteConversation(this._id, conversationId);
297
- // Optimistic local update — remove from cache and emit event
298
- // in case the server doesn't send a conversation_updated event for deletes
299
- if (this._conversations[conversationId]) {
300
- delete this._conversations[conversationId];
301
- this.emit('conversationUpdated', {
302
- conversationId,
303
- source: 'local_user',
304
- });
305
- }
306
239
  }
307
240
  /**
308
- * Get a handle for a specific conversation.
241
+ * Get a handle for a conversation. The handle holds its own interaction tree
242
+ * + cursor; call {@link ConversationHandle.load} to fetch an existing
243
+ * conversation's contents, or just {@link ConversationHandle.prompt} to start
244
+ * a new one (SSE fills the tree). Consumers feed SSE updates into the handle
245
+ * via {@link ConversationHandle.applyUpdate}.
309
246
  */
310
247
  conversation(conversationId) {
311
248
  return new ConversationHandle(this, conversationId);
312
249
  }
250
+ /** @internal — fetch a conversation's full contents from the server. */
251
+ async _fetchConversationImpl(conversationId) {
252
+ return this._graphqlClient.getConversation(this._id, conversationId);
253
+ }
313
254
  /**
314
255
  * Close this space session and clean up resources.
315
256
  */
@@ -351,10 +292,8 @@ export class SpaceOperations extends EventEmitter {
351
292
  async clearHistory() {
352
293
  this._logger.warn('[RoolSpace] clearHistory() is a no-op: checkpoint history is now managed by the server.');
353
294
  }
354
- davHeaders(conversationId, interactionId) {
355
- const headers = new Headers({
356
- 'X-Rool-Conversation-Id': conversationId,
357
- });
295
+ davHeaders(interactionId) {
296
+ const headers = new Headers();
358
297
  if (interactionId)
359
298
  headers.set('X-Rool-Interaction-Id', interactionId);
360
299
  return headers;
@@ -398,19 +337,15 @@ export class SpaceOperations extends EventEmitter {
398
337
  }
399
338
  return result;
400
339
  }
401
- /** Get an object's cached audit information. */
402
- stat(path) {
403
- return this._objectStats.get(objectPath(path));
404
- }
405
- /** @internal */
406
- async _putObjectImpl(path, body, conversationId) {
340
+ /** Create or replace an object JSON file. */
341
+ async putObject(path, body) {
407
342
  const canonical = objectPath(path);
408
343
  const optimistic = objectFromBody(canonical, body);
409
344
  try {
410
345
  const interactionId = generateEntityId();
411
346
  await this._webdav.put(canonical, JSON.stringify(body), {
412
347
  contentType: 'application/json',
413
- headers: this.davHeaders(conversationId, interactionId),
348
+ headers: this.davHeaders(interactionId),
414
349
  });
415
350
  const fresh = await this.getObject(canonical) ?? optimistic;
416
351
  return { object: fresh, message: `Put ${canonical}` };
@@ -421,8 +356,8 @@ export class SpaceOperations extends EventEmitter {
421
356
  throw error;
422
357
  }
423
358
  }
424
- /** @internal */
425
- async _patchObjectImpl(path, options, conversationId) {
359
+ /** Patch an existing object JSON file. */
360
+ async patchObject(path, options) {
426
361
  const canonical = objectPath(path);
427
362
  const data = options.data ?? {};
428
363
  const current = await this.readObject(canonical);
@@ -435,7 +370,7 @@ export class SpaceOperations extends EventEmitter {
435
370
  await this._webdav.put(canonical, JSON.stringify(body), {
436
371
  contentType: 'application/json',
437
372
  ifMatch: current.etag ?? undefined,
438
- headers: this.davHeaders(conversationId, interactionId),
373
+ headers: this.davHeaders(interactionId),
439
374
  });
440
375
  const fresh = await this.getObject(canonical) ?? optimistic;
441
376
  return { object: fresh, message: `Patched ${canonical}` };
@@ -446,23 +381,22 @@ export class SpaceOperations extends EventEmitter {
446
381
  throw error;
447
382
  }
448
383
  }
449
- /** @internal */
450
- async _moveObjectImpl(from, to, options, conversationId) {
384
+ /** Move (rename/relocate) an object. */
385
+ async moveObject(from, to, options) {
451
386
  const fromPath = objectPath(from);
452
387
  const toPath = objectPath(to);
453
388
  const optimistic = objectFromBody(toPath, options?.body ?? {});
454
389
  try {
455
390
  const interactionId = generateEntityId();
456
391
  await this._webdav.move(fromPath, toPath, {
457
- headers: this.davHeaders(conversationId, interactionId),
392
+ headers: this.davHeaders(interactionId),
458
393
  });
459
394
  if (options?.body) {
460
395
  await this._webdav.put(toPath, JSON.stringify(options.body), {
461
396
  contentType: 'application/json',
462
- headers: this.davHeaders(conversationId, interactionId),
397
+ headers: this.davHeaders(interactionId),
463
398
  });
464
399
  }
465
- this._objectStats.delete(fromPath);
466
400
  const fresh = await this.getObject(toPath) ?? optimistic;
467
401
  return { object: fresh, message: `Moved ${fromPath} to ${toPath}` };
468
402
  }
@@ -472,8 +406,8 @@ export class SpaceOperations extends EventEmitter {
472
406
  throw error;
473
407
  }
474
408
  }
475
- /** @internal */
476
- async _deleteObjectsImpl(paths, conversationId) {
409
+ /** Delete object JSON files by path. */
410
+ async deleteObjects(paths) {
477
411
  if (paths.length === 0)
478
412
  return;
479
413
  const canonical = paths.map(objectPath);
@@ -481,9 +415,8 @@ export class SpaceOperations extends EventEmitter {
481
415
  const interactionId = generateEntityId();
482
416
  for (const path of canonical) {
483
417
  await this._webdav.delete(path, {
484
- headers: this.davHeaders(conversationId, interactionId),
418
+ headers: this.davHeaders(interactionId),
485
419
  });
486
- this._objectStats.delete(path);
487
420
  }
488
421
  }
489
422
  catch (error) {
@@ -492,158 +425,95 @@ export class SpaceOperations extends EventEmitter {
492
425
  throw error;
493
426
  }
494
427
  }
495
- /** Get the current schema for this space. */
496
- getSchema() {
497
- return this._schema;
498
- }
499
- /** @internal */
500
- async _createCollectionImpl(name, fields, options, conversationId) {
501
- if (this._schema[name]) {
502
- throw new Error(`Collection "${name}" already exists`);
503
- }
504
- // Optimistic local update
505
- const optimisticDef = collectionDef(fields, options);
506
- this._schema[name] = optimisticDef;
507
- try {
508
- await this._webdav.mkcol(collectionPath(name), { headers: this.davHeaders(conversationId, generateEntityId()) });
509
- await this._webdav.put(schemaPath(name), JSON.stringify(optimisticDef), {
510
- contentType: 'application/json',
511
- headers: this.davHeaders(conversationId, generateEntityId()),
512
- });
513
- this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
514
- return optimisticDef;
515
- }
516
- catch (error) {
517
- this._logger.error('[RoolSpace] Failed to create collection:', error);
518
- delete this._schema[name];
519
- throw error;
520
- }
521
- }
522
- /** @internal */
523
- async _alterCollectionImpl(name, fields, options, conversationId) {
524
- if (!this._schema[name]) {
525
- throw new Error(`Collection "${name}" not found`);
526
- }
527
- const previous = this._schema[name];
528
- this._schema[name] = collectionDef(fields, options);
529
- try {
530
- const updated = this._schema[name];
531
- await this._webdav.put(schemaPath(name), JSON.stringify(updated), {
532
- contentType: 'application/json',
533
- headers: this.davHeaders(conversationId, generateEntityId()),
534
- });
535
- this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
536
- return updated;
537
- }
538
- catch (error) {
539
- this._logger.error('[RoolSpace] Failed to alter collection:', error);
540
- this._schema[name] = previous;
541
- throw error;
542
- }
543
- }
544
- /** @internal */
545
- async _dropCollectionImpl(name, conversationId) {
546
- if (!this._schema[name]) {
547
- throw new Error(`Collection "${name}" not found`);
548
- }
549
- const previous = this._schema[name];
550
- delete this._schema[name];
428
+ /**
429
+ * Read space metadata from `/space/.meta.json`. Returns `{}` when the space has
430
+ * no metadata file yet. Stateless — callers (e.g. a reactive wrapper) cache and
431
+ * re-fetch this on their own schedule, typically when a file-tree sync reports
432
+ * the node changed.
433
+ */
434
+ async readMeta() {
551
435
  try {
552
- await this._webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(conversationId, generateEntityId()) });
553
- this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
436
+ const response = await this._webdav.get('/space/.meta.json');
437
+ return jsonObject(await response.json(), 'space meta');
554
438
  }
555
439
  catch (error) {
556
- this._logger.error('[RoolSpace] Failed to drop collection:', error);
557
- this._schema[name] = previous;
440
+ if (error instanceof WebDAVError && error.status === 404)
441
+ return {};
558
442
  throw error;
559
443
  }
560
444
  }
561
- /** @internal */
562
- _getSystemInstructionImpl(conversationId) {
563
- return this._conversations[conversationId]?.systemInstruction;
564
- }
565
- /** @internal */
566
- async _setSystemInstructionImpl(instruction, conversationId) {
567
- this._ensureConversationImpl(conversationId);
568
- const conv = this._conversations[conversationId];
569
- const previousInstruction = conv.systemInstruction;
570
- if (instruction === null) {
571
- delete conv.systemInstruction;
572
- }
573
- else {
574
- conv.systemInstruction = instruction;
575
- }
576
- this.emit('conversationUpdated', {
577
- conversationId,
578
- source: 'local_user',
445
+ /**
446
+ * Write the full metadata blob to `/space/.meta.json`, attributed to a
447
+ * conversation. Callers compose the blob (e.g. read-merge-write) — this does no
448
+ * merging.
449
+ */
450
+ async writeMeta(meta) {
451
+ await this._webdav.put('/space/.meta.json', JSON.stringify(meta), {
452
+ contentType: 'application/json',
453
+ headers: this.davHeaders(generateEntityId()),
579
454
  });
580
- try {
581
- await this._graphqlClient.updateConversation(this._id, conversationId, { systemInstruction: instruction });
582
- }
583
- catch (error) {
584
- this._logger.error('[RoolSpace] Failed to set system instruction:', error);
585
- if (previousInstruction === undefined) {
586
- delete conv.systemInstruction;
455
+ }
456
+ /**
457
+ * Read the collection schema: one `/space/<name>/.schema.json` per collection
458
+ * directory under `/space`. Returns `{}` for a space with no collections.
459
+ * Stateless reactive callers re-fetch when a `.schema.json` node changes.
460
+ */
461
+ async readSchema() {
462
+ const listing = await this._webdav.propfind('/space', { depth: '1', props: ['resourcetype'] });
463
+ const collections = listing.responses
464
+ .filter((r) => r.isCollection && r.path !== '/space')
465
+ .map((r) => r.path.split('/').pop());
466
+ const entries = await Promise.all(collections.map(async (name) => {
467
+ try {
468
+ const response = await this._webdav.get(`/space/${name}/.schema.json`);
469
+ return [name, jsonObject(await response.json(), `schema ${name}`)];
587
470
  }
588
- else {
589
- conv.systemInstruction = previousInstruction;
471
+ catch (error) {
472
+ if (error instanceof WebDAVError && error.status === 404)
473
+ return null;
474
+ throw error;
590
475
  }
591
- throw error;
592
- }
476
+ }));
477
+ const schema = {};
478
+ for (const entry of entries)
479
+ if (entry)
480
+ schema[entry[0]] = entry[1];
481
+ return schema;
593
482
  }
594
- /** @internal */
595
- async _renameConversationImpl(name, conversationId) {
596
- this._ensureConversationImpl(conversationId);
597
- const conv = this._conversations[conversationId];
598
- const previousName = conv.name;
599
- conv.name = name;
600
- this.emit('conversationUpdated', {
601
- conversationId,
602
- source: 'local_user',
483
+ /** Create a new collection (MKCOL + schema JSON). */
484
+ async createCollection(name, fields, options) {
485
+ const def = collectionDef(fields, options);
486
+ await this._webdav.mkcol(collectionPath(name), { headers: this.davHeaders(generateEntityId()) });
487
+ await this._webdav.put(schemaPath(name), JSON.stringify(def), {
488
+ contentType: 'application/json',
489
+ headers: this.davHeaders(generateEntityId()),
603
490
  });
604
- try {
605
- await this._graphqlClient.updateConversation(this._id, conversationId, { name });
606
- }
607
- catch (error) {
608
- this._logger.error('[RoolSpace] Failed to rename conversation:', error);
609
- conv.name = previousName;
610
- throw error;
611
- }
491
+ return def;
612
492
  }
613
- /** @internal */
614
- _ensureConversationImpl(conversationId) {
615
- if (!this._conversations[conversationId]) {
616
- this._conversations[conversationId] = {
617
- createdAt: Date.now(),
618
- createdBy: this._userId,
619
- interactions: {},
620
- };
621
- }
622
- }
623
- /** @internal */
624
- _setMetadataImpl(key, value, conversationId) {
625
- this._meta[key] = value;
626
- this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
627
- // Fire-and-forget server call
628
- this._graphqlClient.setSpaceMeta(this._id, this._meta, conversationId)
629
- .catch((error) => {
630
- this._logger.error('[RoolSpace] Failed to set meta:', error);
493
+ /** Alter an existing collection's schema JSON. */
494
+ async alterCollection(name, fields, options) {
495
+ const def = collectionDef(fields, options);
496
+ await this._webdav.put(schemaPath(name), JSON.stringify(def), {
497
+ contentType: 'application/json',
498
+ headers: this.davHeaders(generateEntityId()),
631
499
  });
500
+ return def;
632
501
  }
633
- /** Get a space-level metadata value. */
634
- getMetadata(key) {
635
- return this._meta[key];
502
+ /** Drop a collection (DELETE). */
503
+ async dropCollection(name) {
504
+ await this._webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(generateEntityId()) });
636
505
  }
637
- /** Get all space-level metadata. */
638
- getAllMetadata() {
639
- return this._meta;
506
+ /** @internal */
507
+ async _setSystemInstructionImpl(instruction, conversationId) {
508
+ await this._graphqlClient.updateConversation(this._id, conversationId, { systemInstruction: instruction });
509
+ }
510
+ /** @internal */
511
+ async _renameConversationImpl(name, conversationId) {
512
+ await this._graphqlClient.updateConversation(this._id, conversationId, { name });
640
513
  }
641
514
  /** @internal */
642
515
  async _promptImpl(prompt, options, conversationId) {
643
- const { attachments, parentInteractionId: explicitParent, signal, interactionId: providedId, ...rest } = options ?? {};
644
- // Callers may supply the id so they can render an optimistic message keyed by
645
- // the id the server will echo back (see PromptOptions.interactionId).
646
- const interactionId = providedId ?? generateEntityId();
516
+ const { attachments, signal, interactionId = generateEntityId(), parentInteractionId = null, ...rest } = options ?? {};
647
517
  let attachmentRefs;
648
518
  if (attachments?.length) {
649
519
  attachmentRefs = await Promise.all(attachments.map(async (attachment) => {
@@ -651,12 +521,6 @@ export class SpaceOperations extends EventEmitter {
651
521
  return machineUri(path);
652
522
  }));
653
523
  }
654
- // Auto-continue from active leaf if no explicit parent provided
655
- const parentInteractionId = explicitParent !== undefined
656
- ? explicitParent
657
- : (this._getActiveLeafImpl(conversationId) ?? null);
658
- // Optimistically set active leaf before the server call.
659
- this._activeLeaves.set(conversationId, interactionId);
660
524
  let onAbort;
661
525
  if (signal) {
662
526
  if (signal.aborted) {
@@ -691,6 +555,7 @@ export class SpaceOperations extends EventEmitter {
691
555
  return {
692
556
  message: result.message,
693
557
  objects,
558
+ creditsUsed: result.creditsUsed,
694
559
  };
695
560
  }
696
561
  /**
@@ -702,19 +567,6 @@ export class SpaceOperations extends EventEmitter {
702
567
  async stopInteraction(interactionId) {
703
568
  return this._graphqlClient.stopInteraction(this._id, interactionId);
704
569
  }
705
- /** @internal */
706
- async _stopImpl(conversationId) {
707
- const leafId = this._getActiveLeafImpl(conversationId);
708
- if (!leafId)
709
- return false;
710
- const interactions = this._conversations[conversationId]?.interactions;
711
- const interaction = interactions && !Array.isArray(interactions) ? interactions[leafId] : undefined;
712
- // Skip the round trip when we already know the interaction has settled.
713
- if (interaction && (interaction.status === 'done' || interaction.status === 'error')) {
714
- return false;
715
- }
716
- return this.stopInteraction(leafId);
717
- }
718
570
  /**
719
571
  * Fetch an external URL via the server proxy, bypassing CORS restrictions.
720
572
  */
@@ -738,46 +590,59 @@ export class SpaceOperations extends EventEmitter {
738
590
  applySpaceContentEvent(event) {
739
591
  if (this._closed)
740
592
  return;
593
+ if (event.type !== 'conversation_updated' || !event.conversationId)
594
+ return;
741
595
  const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
742
- switch (event.type) {
743
- case 'conversation_updated':
744
- if (event.conversationId) {
745
- const prev = this._conversations[event.conversationId];
746
- if (event.conversation) {
747
- this._conversations[event.conversationId] = event.conversation;
748
- }
749
- else {
750
- delete this._conversations[event.conversationId];
751
- }
752
- if (JSON.stringify(prev) === JSON.stringify(event.conversation))
753
- break;
754
- if (event.conversation && !Array.isArray(event.conversation.interactions)) {
755
- const currentLeaf = this._getActiveLeafImpl(event.conversationId);
756
- if (currentLeaf) {
757
- for (const ix of Object.values(event.conversation.interactions)) {
758
- if (ix.parentId === currentLeaf && ix.id !== currentLeaf) {
759
- this._activeLeaves.set(event.conversationId, ix.id);
760
- break;
761
- }
762
- }
763
- }
764
- }
765
- this.emit('conversationUpdated', {
766
- conversationId: event.conversationId,
767
- source: changeSource,
768
- });
769
- }
770
- break;
596
+ const conversation = event.conversation ?? null;
597
+ this._updateConversationMeta(event.conversationId, conversation);
598
+ this.emit('conversationUpdated', {
599
+ conversationId: event.conversationId,
600
+ conversation,
601
+ source: changeSource,
602
+ });
603
+ }
604
+ /** Maintain the conversation meta list from an SSE update. */
605
+ _updateConversationMeta(id, conversation) {
606
+ if (conversation === null) {
607
+ this._conversationMeta = this._conversationMeta.filter((m) => m.id !== id);
608
+ return;
609
+ }
610
+ const interactionCount = conversation.interactions ? Object.keys(conversation.interactions).length : 0;
611
+ const entry = {
612
+ id,
613
+ name: conversation.name ?? null,
614
+ systemInstruction: conversation.systemInstruction ?? null,
615
+ createdAt: conversation.createdAt,
616
+ createdBy: conversation.createdBy,
617
+ interactionCount,
618
+ updatedAt: Date.now(),
619
+ };
620
+ const idx = this._conversationMeta.findIndex((m) => m.id === id);
621
+ if (idx >= 0) {
622
+ this._conversationMeta = [
623
+ ...this._conversationMeta.slice(0, idx),
624
+ entry,
625
+ ...this._conversationMeta.slice(idx + 1),
626
+ ];
627
+ }
628
+ else {
629
+ this._conversationMeta = [entry, ...this._conversationMeta];
771
630
  }
772
631
  }
773
632
  }
774
633
  /**
775
- * A lightweight handle for a specific conversation.
634
+ * A stateful handle for a single conversation. Holds the interaction tree and a
635
+ * client-side branch cursor (active leaf). Acquired on demand via
636
+ * {@link SpaceOperations.conversation}; a fresh handle each call — the SDK
637
+ * holds no handle map. The handle starts unloaded; call {@link load} to fetch
638
+ * an existing conversation's contents, or just {@link prompt} to start a new
639
+ * one. Feed SSE updates via {@link applyUpdate}.
776
640
  */
777
641
  export class ConversationHandle {
778
- /** @internal */
779
642
  _space;
780
643
  _conversationId;
644
+ _data = null;
645
+ _activeLeaf;
781
646
  /** @internal */
782
647
  constructor(space, conversationId) {
783
648
  this._space = space;
@@ -785,25 +650,75 @@ export class ConversationHandle {
785
650
  }
786
651
  /** The conversation ID this handle is scoped to. */
787
652
  get conversationId() { return this._conversationId; }
788
- /** Get the active branch of this conversation as a flat array (root → leaf). */
653
+ /** Whether the conversation contents have been loaded. */
654
+ get loaded() { return this._data !== null; }
655
+ /** Fetch the full conversation from the server. Skips the overwrite if SSE
656
+ * already filled the tree during the fetch (SSE is the real-time source of
657
+ * truth). Pass `true` to force a reload — used after a reconnect `reset`,
658
+ * where we may have missed SSE updates while disconnected. */
659
+ async load(force = false) {
660
+ const data = await this._space._fetchConversationImpl(this._conversationId);
661
+ if (!force && this._data)
662
+ return;
663
+ this._data = data;
664
+ this._activeLeaf = this._data ? findDefaultLeaf(this._data.interactions) : undefined;
665
+ }
666
+ /** Apply an SSE conversation update — updates the tree + cursor. Pass `null`
667
+ * for a deletion. */
668
+ applyUpdate(conversation) {
669
+ if (conversation === null) {
670
+ this._data = null;
671
+ this._activeLeaf = undefined;
672
+ return;
673
+ }
674
+ // Advance the cursor to a new child of the current leaf, if one appeared.
675
+ const currentLeaf = this._activeLeaf;
676
+ if (currentLeaf) {
677
+ for (const ix of Object.values(conversation.interactions)) {
678
+ if (ix.parentId === currentLeaf && ix.id !== currentLeaf) {
679
+ this._activeLeaf = ix.id;
680
+ break;
681
+ }
682
+ }
683
+ }
684
+ this._data = conversation;
685
+ if (this._activeLeaf === undefined) {
686
+ this._activeLeaf = findDefaultLeaf(conversation.interactions);
687
+ }
688
+ }
689
+ /** Get the active branch as a flat array (root → leaf). Empty until loaded. */
789
690
  getInteractions() {
790
- return this._space._getInteractionsImpl(this._conversationId);
691
+ if (!this._data)
692
+ return [];
693
+ const leaf = this._activeLeaf ?? findDefaultLeaf(this._data.interactions);
694
+ if (!leaf)
695
+ return [];
696
+ return walkBranch(this._data.interactions, leaf);
791
697
  }
792
- /** Get the full interaction tree as a record. */
698
+ /** Get the full interaction tree as a record. Empty until loaded. */
793
699
  getTree() {
794
- return this._space._getTreeImpl(this._conversationId);
700
+ if (!this._data)
701
+ return {};
702
+ return this._data.interactions;
795
703
  }
796
704
  /** Get the active leaf interaction ID, or undefined if empty. */
797
705
  get activeLeafId() {
798
- return this._space._getActiveLeafImpl(this._conversationId);
706
+ if (this._activeLeaf)
707
+ return this._activeLeaf;
708
+ if (!this._data)
709
+ return undefined;
710
+ return findDefaultLeaf(this._data.interactions);
799
711
  }
800
712
  /** Switch to a different branch by setting the active leaf. */
801
713
  setActiveLeaf(interactionId) {
802
- this._space._setActiveLeafImpl(interactionId, this._conversationId);
714
+ if (!this._data || !this._data.interactions[interactionId]) {
715
+ throw new Error(`Interaction "${interactionId}" not found in conversation "${this._conversationId}"`);
716
+ }
717
+ this._activeLeaf = interactionId;
803
718
  }
804
719
  /** Get the system instruction for this conversation. */
805
720
  getSystemInstruction() {
806
- return this._space._getSystemInstructionImpl(this._conversationId);
721
+ return this._data?.systemInstruction;
807
722
  }
808
723
  /** Set the system instruction for this conversation. Pass null to clear. */
809
724
  async setSystemInstruction(instruction) {
@@ -817,29 +732,16 @@ export class ConversationHandle {
817
732
  async delete() {
818
733
  return this._space.deleteConversation(this._conversationId);
819
734
  }
820
- /** Create or replace an object JSON file. */
821
- async putObject(path, body) {
822
- return this._space._putObjectImpl(path, body, this._conversationId);
823
- }
824
- /** Patch an existing object JSON file. */
825
- async patchObject(path, options) {
826
- return this._space._patchObjectImpl(path, options, this._conversationId);
827
- }
828
- /** Move (rename/relocate) an object. */
829
- async moveObject(from, to, options) {
830
- return this._space._moveObjectImpl(from, to, options, this._conversationId);
831
- }
832
- /** Delete object JSON files by path. */
833
- async deleteObjects(paths) {
834
- return this._space._deleteObjectsImpl(paths, this._conversationId);
835
- }
836
- /** @deprecated Use deleteObjects instead. */
837
- async deletePaths(paths) {
838
- return this.deleteObjects(paths);
839
- }
840
- /** Send a prompt to the AI agent, scoped to this conversation's history. */
735
+ /** Send a prompt to the AI agent, scoped to this conversation's history.
736
+ * Auto-continues from the active leaf unless `parentInteractionId` is set. */
841
737
  async prompt(text, options) {
842
- return this._space._promptImpl(text, options, this._conversationId);
738
+ const interactionId = options?.interactionId ?? generateEntityId();
739
+ const parentInteractionId = options?.parentInteractionId !== undefined
740
+ ? options.parentInteractionId
741
+ : (this.activeLeafId ?? null);
742
+ // Optimistic: advance the cursor to the new interaction.
743
+ this._activeLeaf = interactionId;
744
+ return this._space._promptImpl(text, { ...options, interactionId, parentInteractionId }, this._conversationId);
843
745
  }
844
746
  /**
845
747
  * Stop this conversation's in-flight interaction, if any. No-op returning
@@ -847,22 +749,13 @@ export class ConversationHandle {
847
749
  * {@link RoolSpace.stopInteraction}.
848
750
  */
849
751
  async stop() {
850
- return this._space._stopImpl(this._conversationId);
851
- }
852
- /** Create a new collection schema. */
853
- async createCollection(name, fields, options) {
854
- return this._space._createCollectionImpl(name, fields, options, this._conversationId);
855
- }
856
- /** Alter an existing collection schema. */
857
- async alterCollection(name, fields, options) {
858
- return this._space._alterCollectionImpl(name, fields, options, this._conversationId);
859
- }
860
- /** Drop a collection schema. */
861
- async dropCollection(name) {
862
- return this._space._dropCollectionImpl(name, this._conversationId);
863
- }
864
- setMetadata(key, value) {
865
- return this._space._setMetadataImpl(key, value, this._conversationId);
752
+ const leafId = this.activeLeafId;
753
+ if (!leafId)
754
+ return false;
755
+ const interaction = this._data?.interactions[leafId];
756
+ if (interaction && (interaction.status === 'done' || interaction.status === 'error'))
757
+ return false;
758
+ return this._space.stopInteraction(leafId);
866
759
  }
867
760
  }
868
761
  //# sourceMappingURL=space-session.js.map