@turingfocus/chat-runtime 0.8.0 → 0.8.1

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.
@@ -1,9 +1,11 @@
1
- import { answerInteractionInputSchema, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, sendMessageInputSchema, } from "@turingfocus/chat-protocol";
1
+ import { answerInteractionInputSchema, getTimelineItemKey, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, sendMessageInputSchema, } from "@turingfocus/chat-protocol";
2
2
  import { createGatewayNotificationQueue, } from "./gateway-notification-queue.js";
3
3
  import { cloneImmutable, deepEqual } from "./immutable.js";
4
4
  import { applySnapshotUpdate, applyRuntimeSnapshotError, createSnapshotState, rebaseSnapshotState, updateConversationId, updateSnapshotState, } from "./snapshot-state.js";
5
5
  import { SnapshotStore, } from "./snapshot-store.js";
6
- import { mergeHistoryTimeline } from "./timeline.js";
6
+ import { createTimelineState, mergeHistoryTimeline } from "./timeline.js";
7
+ import { ConversationCache, mergeCachedHistory, } from "./conversation-cache.js";
8
+ import { cachedDisplaySnapshot } from "./cache-record.js";
7
9
  import { areComposerLongTextsValid, emptyComposerDraft, rebaseComposerLongTexts, resolveComposerDraftText, } from "./composer-draft.js";
8
10
  const runtimeFailure = (code, message, conversationId) => ({
9
11
  ok: false,
@@ -42,6 +44,19 @@ const runCleanup = async (cleanup, onError) => {
42
44
  */
43
45
  export class ChatClient {
44
46
  #gateway;
47
+ #cache;
48
+ #cacheListeners = new Set();
49
+ #cacheRequestId = 0;
50
+ #cacheReadOnly = false;
51
+ #cacheWriteScheduled = false;
52
+ #clearedSnapshot = null;
53
+ #cacheState = Object.freeze({
54
+ status: "idle",
55
+ source: "none",
56
+ freshness: "miss",
57
+ unavailableAttachments: 0,
58
+ storageError: false,
59
+ });
45
60
  #onUnhandledError;
46
61
  #snapshotStore;
47
62
  #disposed = false;
@@ -66,12 +81,141 @@ export class ChatClient {
66
81
  #pendingHandoff;
67
82
  constructor(options) {
68
83
  this.#gateway = options.gateway;
84
+ this.#cache =
85
+ options.cache === false
86
+ ? undefined
87
+ : new ConversationCache({
88
+ ...options.cache,
89
+ onEvent: (event) => {
90
+ if (event.kind === "storage-error" ||
91
+ event.kind === "invalid-record" ||
92
+ event.kind === "write-conflict")
93
+ this.#setCacheState({
94
+ ...this.#cacheState,
95
+ storageError: true,
96
+ });
97
+ if (options.cache)
98
+ options.cache.onEvent?.(event);
99
+ },
100
+ }, (id) => {
101
+ if (this.getSnapshot()?.conversation.id !== id)
102
+ this.#discardComposerDraft(id);
103
+ });
104
+ if (this.#cache === undefined)
105
+ this.#cacheState = Object.freeze({
106
+ ...this.#cacheState,
107
+ status: "disabled",
108
+ });
69
109
  this.#onUnhandledError = options.onUnhandledError;
70
110
  this.#snapshotStore = new SnapshotStore({
71
111
  onListenerError: (cause) => {
72
112
  this.#reportUnhandledError({ cause, source: "listener" });
73
113
  },
74
114
  });
115
+ if (options.cache !== false && options.cache?.storage !== undefined) {
116
+ this.#snapshotStore.subscribe(() => this.#scheduleCacheWrite());
117
+ }
118
+ }
119
+ getCacheState() {
120
+ return this.#cacheState;
121
+ }
122
+ subscribeCacheState(listener) {
123
+ const isDisposed = () => this.#disposed;
124
+ let closed = this.#disposed;
125
+ if (!closed)
126
+ this.#cacheListeners.add(listener);
127
+ return {
128
+ get closed() {
129
+ return closed || isDisposed();
130
+ },
131
+ dispose: () => {
132
+ closed = true;
133
+ this.#cacheListeners.delete(listener);
134
+ },
135
+ };
136
+ }
137
+ async clearConversationCache(conversationId, options) {
138
+ if (this.#disposed)
139
+ return;
140
+ if (this.#cacheState.conversationId === conversationId)
141
+ this.cancelPendingConversationLoad();
142
+ if (this.getSnapshot()?.conversation.id === conversationId)
143
+ this.#clearedSnapshot = this.getSnapshot();
144
+ this.#discardComposerDraft(conversationId);
145
+ const clearing = this.#cache?.clear(conversationId, options);
146
+ if (this.getSnapshot()?.conversation.id === conversationId)
147
+ this.#clearCachedView();
148
+ if (this.#cacheState.conversationId === conversationId)
149
+ this.#setCacheState({
150
+ ...this.#cacheState,
151
+ source: "none",
152
+ freshness: "miss",
153
+ });
154
+ await clearing;
155
+ }
156
+ async clearCache(options) {
157
+ if (this.#disposed)
158
+ return;
159
+ this.cancelPendingConversationLoad();
160
+ this.#clearedSnapshot = this.getSnapshot();
161
+ for (const id of [...this.#composerDrafts.keys()])
162
+ this.#discardComposerDraft(id);
163
+ const clearing = this.#cache?.clear(undefined, options);
164
+ this.#clearCachedView();
165
+ this.#setCacheState({
166
+ status: this.#cache === undefined ? "disabled" : "idle",
167
+ source: "none",
168
+ freshness: "miss",
169
+ unavailableAttachments: 0,
170
+ storageError: this.#cache?.storageError ?? false,
171
+ });
172
+ await clearing;
173
+ }
174
+ #clearCachedView() {
175
+ const snapshot = this.getSnapshot();
176
+ if (!this.#cacheReadOnly || snapshot === null)
177
+ return;
178
+ // Clearing local content does not delete the server conversation or selection.
179
+ this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot({
180
+ ...snapshot,
181
+ timeline: [],
182
+ pageInfo: { hasPreviousPage: false },
183
+ })));
184
+ }
185
+ async flushCache(options) {
186
+ this.#captureCache(options);
187
+ await this.#cache?.flush();
188
+ }
189
+ #captureCache(options) {
190
+ const snapshot = this.getSnapshot();
191
+ if (this.#disposed ||
192
+ this.#cacheReadOnly ||
193
+ snapshot === null ||
194
+ snapshot === this.#clearedSnapshot)
195
+ return;
196
+ this.#cache?.put(snapshot, this.getComposerDraft(snapshot.conversation.id), options);
197
+ }
198
+ #scheduleCacheWrite() {
199
+ if (this.#cacheWriteScheduled || this.#disposed || this.#cacheReadOnly)
200
+ return;
201
+ this.#cacheWriteScheduled = true;
202
+ void Promise.resolve().then(() => {
203
+ this.#cacheWriteScheduled = false;
204
+ this.#captureCache({ deadlineAt: Date.now() + 5000 });
205
+ });
206
+ }
207
+ #setCacheState(state) {
208
+ if (this.#disposed || this.#cache === undefined)
209
+ return;
210
+ this.#cacheState = Object.freeze(state);
211
+ for (const listener of this.#cacheListeners) {
212
+ try {
213
+ listener();
214
+ }
215
+ catch (cause) {
216
+ this.#reportUnhandledError({ cause, source: "listener" });
217
+ }
218
+ }
75
219
  }
76
220
  get disposed() {
77
221
  return this.#disposed;
@@ -95,6 +239,18 @@ export class ChatClient {
95
239
  this.#composerDrafts.set(conversationId, empty);
96
240
  return empty;
97
241
  }
242
+ #discardComposerDraft(conversationId) {
243
+ this.#composerDrafts.delete(conversationId);
244
+ const empty = emptyComposerDraft(conversationId);
245
+ for (const listener of this.#composerListeners) {
246
+ try {
247
+ listener(empty);
248
+ }
249
+ catch (cause) {
250
+ this.#reportUnhandledError({ cause, source: "listener" });
251
+ }
252
+ }
253
+ }
98
254
  setComposerDraft(input) {
99
255
  if (this.#disposed)
100
256
  return this.getComposerDraft(input.conversationId);
@@ -113,6 +269,10 @@ export class ChatClient {
113
269
  revision: current.revision + 1,
114
270
  });
115
271
  this.#composerDrafts.set(input.conversationId, next);
272
+ this.#cache?.updateDraft(input.conversationId, next, {
273
+ deadlineAt: Date.now() + 5000,
274
+ }, input.attachments === undefined);
275
+ this.#scheduleCacheWrite();
116
276
  for (const listener of this.#composerListeners) {
117
277
  try {
118
278
  listener(next);
@@ -187,6 +347,7 @@ export class ChatClient {
187
347
  * that load completes successfully while transport cleanup settles.
188
348
  */
189
349
  cancelPendingConversationLoad() {
350
+ this.#cacheRequestId += 1;
190
351
  if (this.#disposed || this.#pendingLoadRequestId === undefined)
191
352
  return;
192
353
  this.#loadRequestId += 1;
@@ -292,8 +453,15 @@ export class ChatClient {
292
453
  result.value.deletedConversationId !== parsed.data.conversationId) {
293
454
  return runtimeFailure("validation", "Gateway deleted a different conversation", parsed.data.conversationId);
294
455
  }
456
+ const cacheClear = result.ok
457
+ ? this.#cache
458
+ ?.clear(parsed.data.conversationId, parsed.data)
459
+ .catch(() => undefined)
460
+ : undefined;
295
461
  if (result.ok) {
296
- this.#composerDrafts.delete(parsed.data.conversationId);
462
+ if (this.#cacheState.conversationId === parsed.data.conversationId)
463
+ this.#cacheRequestId += 1;
464
+ this.#discardComposerDraft(parsed.data.conversationId);
297
465
  const sendKeyPrefix = `${parsed.data.conversationId.length}:${parsed.data.conversationId}:`;
298
466
  for (const key of this.#composerSendsInFlight.keys()) {
299
467
  if (key.startsWith(sendKeyPrefix))
@@ -332,9 +500,144 @@ export class ChatClient {
332
500
  this.#snapshotStore.clear();
333
501
  await this.#runSubscriptionCleanup(() => subscription?.dispose(parsed.data), parsed.data.conversationId);
334
502
  }
503
+ await cacheClear;
335
504
  return result;
336
505
  }
337
506
  async loadConversation(input) {
507
+ if (this.#cache === undefined ||
508
+ input.previousCursor !== undefined ||
509
+ this.#disposed)
510
+ return this.#loadConversationFromGateway(input);
511
+ if (isGatewayDeadlineExceeded(input))
512
+ return {
513
+ ok: false,
514
+ error: createGatewayDeadlineExceededError(input.conversationId),
515
+ };
516
+ this.cancelPendingConversationLoad();
517
+ const ticket = this.#cacheRequestId;
518
+ const epoch = this.#cache.epochFor(input.conversationId);
519
+ this.#captureCache(input);
520
+ let entry = this.#cache.peek(input.conversationId);
521
+ let source = entry === undefined ? "none" : "memory";
522
+ if (entry === undefined && this.#cache.hasStorage) {
523
+ entry = await this.#cache.restore(input.conversationId, input);
524
+ if (entry !== undefined)
525
+ source = "storage";
526
+ }
527
+ if (this.#disposed ||
528
+ ticket !== this.#cacheRequestId ||
529
+ epoch !== this.#cache.epochFor(input.conversationId))
530
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
531
+ this.#setCacheState({
532
+ conversationId: input.conversationId,
533
+ status: "syncing",
534
+ source,
535
+ freshness: entry === undefined
536
+ ? this.#cache.wasExpired(input.conversationId)
537
+ ? "expired"
538
+ : "miss"
539
+ : this.#cache.freshness(entry.savedAt),
540
+ ...(entry === undefined ? {} : { savedAt: entry.savedAt }),
541
+ unavailableAttachments: (entry?.unavailableAttachments ?? 0) +
542
+ (entry?.attachmentKeys?.length ?? 0),
543
+ storageError: this.#cache.storageError,
544
+ });
545
+ if (this.#disposed ||
546
+ ticket !== this.#cacheRequestId ||
547
+ epoch !== this.#cache.epochFor(input.conversationId))
548
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
549
+ if (entry !== undefined &&
550
+ (this.getSnapshot()?.conversation.id !== input.conversationId ||
551
+ this.#cacheReadOnly)) {
552
+ const oldSubscription = this.#gatewaySubscription;
553
+ this.#gatewaySubscription = undefined;
554
+ this.#generation += 1;
555
+ this.#conversationEpoch += 1;
556
+ this.#historyRequestId += 1;
557
+ this.#cacheReadOnly = true;
558
+ this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot(entry.snapshot)));
559
+ void this.#runSubscriptionCleanup(() => oldSubscription?.dispose(input), input.conversationId);
560
+ if (ticket === this.#cacheRequestId &&
561
+ epoch === this.#cache.epochFor(input.conversationId))
562
+ this.#restoreCachedDraft(entry, source, input, ticket);
563
+ }
564
+ if (this.#disposed ||
565
+ ticket !== this.#cacheRequestId ||
566
+ epoch !== this.#cache.epochFor(input.conversationId))
567
+ return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
568
+ const result = await this.#loadConversationFromGateway(input);
569
+ if (this.#disposed ||
570
+ ticket !== this.#cacheRequestId ||
571
+ epoch !== this.#cache.epochFor(input.conversationId))
572
+ return result;
573
+ if (result.ok) {
574
+ this.#cacheReadOnly = false;
575
+ this.#captureCache(input);
576
+ this.#setCacheState({
577
+ ...this.#cacheState,
578
+ status: "ready",
579
+ freshness: "fresh",
580
+ savedAt: this.#cache.now(),
581
+ storageError: this.#cache.storageError,
582
+ });
583
+ }
584
+ else {
585
+ this.#setCacheState({
586
+ ...this.#cacheState,
587
+ status: "error",
588
+ storageError: this.#cache.storageError,
589
+ });
590
+ if (result.error.code === "not-found" ||
591
+ result.error.code === "authorization" ||
592
+ result.error.code === "authentication") {
593
+ await this.clearConversationCache(input.conversationId, input).catch(() => undefined);
594
+ }
595
+ }
596
+ return result;
597
+ }
598
+ #restoreCachedDraft(entry, source, input, ticket) {
599
+ const existing = this.#composerDrafts.get(input.conversationId);
600
+ if (existing !== undefined && existing.revision > 0)
601
+ return;
602
+ const draft = this.setComposerDraft({
603
+ conversationId: input.conversationId,
604
+ text: entry.draft.text,
605
+ longTexts: entry.draft.longTexts,
606
+ ...(source === "memory" ? { attachments: entry.draft.attachments } : {}),
607
+ });
608
+ if (source !== "storage")
609
+ return;
610
+ const epoch = this.#cache.epochFor(input.conversationId);
611
+ this.#setCacheState({
612
+ ...this.#cacheState,
613
+ unavailableAttachments: (entry.attachmentKeys?.length ?? 0) +
614
+ (entry.unavailableAttachments ?? 0),
615
+ });
616
+ void this.#cache.restoreAttachments(entry, input).then((restored) => {
617
+ if (this.#disposed ||
618
+ epoch !== this.#cache.epochFor(input.conversationId) ||
619
+ this.getComposerDraft(input.conversationId).revision !== draft.revision)
620
+ return;
621
+ const validated = sendMessageInputSchema.safeParse({
622
+ ...input,
623
+ text: draft.text || " ",
624
+ attachments: restored.attachments,
625
+ });
626
+ this.setComposerDraft({
627
+ conversationId: input.conversationId,
628
+ attachments: validated.success ? restored.attachments : [],
629
+ });
630
+ const unavailable = restored.unavailable +
631
+ (validated.success ? 0 : restored.attachments.length);
632
+ this.#cache.setUnavailableAttachments(input.conversationId, unavailable, input);
633
+ if (ticket === this.#cacheRequestId)
634
+ this.#setCacheState({
635
+ ...this.#cacheState,
636
+ unavailableAttachments: unavailable,
637
+ });
638
+ });
639
+ }
640
+ async #loadConversationFromGateway(input) {
338
641
  if (this.#disposed) {
339
642
  return runtimeFailure("conflict", "ChatClient has already been disposed", input.conversationId);
340
643
  }
@@ -373,7 +676,10 @@ export class ChatClient {
373
676
  await this.#discardGatewaySubscription(subscribed, input);
374
677
  return runtimeFailure("conflict", "Conversation load was superseded", input.conversationId);
375
678
  }
376
- const loaded = await this.#gateway.loadConversation(input);
679
+ let loaded = await this.#gateway.loadConversation(input);
680
+ if (loaded.ok && loaded.value.conversation.id === input.conversationId) {
681
+ loaded = await this.#bridgeCachedHistory(input, loaded.value, requestId);
682
+ }
377
683
  if (!loaded.ok) {
378
684
  this.#clearPendingLoad(requestId);
379
685
  await this.#discardGatewaySubscription(subscribed, input);
@@ -422,9 +728,11 @@ export class ChatClient {
422
728
  this.#pendingLoadConversationId === input.conversationId
423
729
  ? this.#pendingLoadConversationOverride
424
730
  : undefined;
731
+ const cached = this.#cache?.peek(input.conversationId);
732
+ const loadedWithHistory = mergeCachedHistory(loaded.value, cached?.snapshot);
425
733
  const loadedState = createSnapshotState(conversationOverride === undefined
426
- ? loaded.value
427
- : { ...loaded.value, conversation: conversationOverride });
734
+ ? loadedWithHistory
735
+ : { ...loadedWithHistory, conversation: conversationOverride });
428
736
  const currentState = this.#snapshotStore.state;
429
737
  const handoff = this.#pendingHandoff;
430
738
  let nextState = handoff?.requestId === requestId && currentState !== null
@@ -439,6 +747,7 @@ export class ChatClient {
439
747
  nextState = this.#guardInteractionTransition(currentState, nextState);
440
748
  }
441
749
  this.#clearPendingLoad(requestId);
750
+ this.#cacheReadOnly = false;
442
751
  this.#snapshotStore.commit(nextState);
443
752
  if (!this.#disposed)
444
753
  notificationQueue.activate(generation);
@@ -609,6 +918,10 @@ export class ChatClient {
609
918
  dispose(options) {
610
919
  if (this.#disposePromise !== undefined)
611
920
  return this.#disposePromise;
921
+ this.#captureCache(options);
922
+ const cacheClosed = this.#cache?.close();
923
+ this.#cacheListeners.clear();
924
+ this.#cacheRequestId += 1;
612
925
  this.#disposed = true;
613
926
  this.#conversationEpoch += 1;
614
927
  this.#generation += 1;
@@ -629,6 +942,7 @@ export class ChatClient {
629
942
  const subscription = this.#gatewaySubscription;
630
943
  this.#gatewaySubscription = undefined;
631
944
  this.#disposePromise = Promise.allSettled([
945
+ cacheClosed,
632
946
  Promise.resolve().then(() => subscription?.dispose(options)),
633
947
  Promise.resolve().then(() => this.#gateway.dispose(options)),
634
948
  ]).then((results) => {
@@ -641,6 +955,42 @@ export class ChatClient {
641
955
  });
642
956
  return this.#disposePromise;
643
957
  }
958
+ async #bridgeCachedHistory(input, snapshot, requestId) {
959
+ const cached = this.#cache?.peek(input.conversationId);
960
+ if (cached === undefined || cached.snapshot.timeline.length === 0)
961
+ return { ok: true, value: snapshot };
962
+ const keys = new Set(cached.snapshot.timeline.map(getTimelineItemKey));
963
+ const cursors = new Set();
964
+ let current = snapshot;
965
+ while (current.pageInfo.hasPreviousPage &&
966
+ !current.timeline.some((item) => keys.has(getTimelineItemKey(item)))) {
967
+ const cursor = current.pageInfo.previousCursor;
968
+ if (this.#disposed || requestId !== this.#loadRequestId)
969
+ return runtimeFailure("conflict", "Cache synchronization was superseded", input.conversationId);
970
+ if (isGatewayDeadlineExceeded(input))
971
+ return {
972
+ ok: false,
973
+ error: createGatewayDeadlineExceededError(input.conversationId),
974
+ };
975
+ if (cursor === undefined || cursors.has(cursor) || cursors.size >= 50)
976
+ return runtimeFailure("conflict", "Cache synchronization could not establish continuous history", input.conversationId);
977
+ cursors.add(cursor);
978
+ const page = await this.#gateway.loadConversation({
979
+ ...input,
980
+ previousCursor: cursor,
981
+ });
982
+ if (!page.ok)
983
+ return page;
984
+ if (page.value.conversation.id !== input.conversationId)
985
+ return runtimeFailure("validation", "Gateway returned history for a different conversation", input.conversationId);
986
+ current = {
987
+ ...current,
988
+ timeline: mergeHistoryTimeline(createTimelineState(current.timeline), page.value.timeline).items,
989
+ pageInfo: page.value.pageInfo,
990
+ };
991
+ }
992
+ return { ok: true, value: current };
993
+ }
644
994
  async #loadHistory(input) {
645
995
  const active = this.#activeState(input.conversationId);
646
996
  if (!active.ok)
@@ -702,6 +1052,8 @@ export class ChatClient {
702
1052
  if (this.#snapshotStore.state?.snapshot.conversation.id !== conversationId) {
703
1053
  return runtimeFailure("conflict", "Conversation is not active in this ChatClient", conversationId);
704
1054
  }
1055
+ if (this.#cacheReadOnly)
1056
+ return runtimeFailure("conflict", "Cached conversation is read-only until synchronization succeeds", conversationId);
705
1057
  const lifecycle = this.#snapshotStore.state.snapshot.lifecycle;
706
1058
  if (lifecycle !== undefined && !isChatLifecycleOperable(lifecycle)) {
707
1059
  return runtimeFailure(lifecycle.status === "auth-required"
@@ -792,7 +1144,15 @@ export class ChatClient {
792
1144
  return;
793
1145
  }
794
1146
  const state = this.#snapshotStore.latestState();
795
- this.#snapshotStore.commit(this.#guardInteractionTransition(state, applySnapshotUpdate(state, update)));
1147
+ const prepared = this.#cache !== undefined &&
1148
+ update.kind === "snapshot.replace" &&
1149
+ update.snapshot.conversation.id === state.snapshot.conversation.id
1150
+ ? {
1151
+ ...update,
1152
+ snapshot: mergeCachedHistory(update.snapshot, state.snapshot),
1153
+ }
1154
+ : update;
1155
+ this.#snapshotStore.commit(this.#guardInteractionTransition(state, applySnapshotUpdate(state, prepared)));
796
1156
  }
797
1157
  #guardInteractionTransition(current, candidate) {
798
1158
  const currentInteraction = current.snapshot.pendingInteraction;