@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.3

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.
Files changed (63) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +111 -2
  63. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
@@ -3,6 +3,9 @@
3
3
  const limit = 100 * 1024 * 1024;
4
4
  let opening, writes = Promise.resolve(), displayVersion = 0;
5
5
  const urls = new Map();
6
+ function metric(detail) {
7
+ try { window.codexNative?.reportSyncMetric?.(detail); } catch {}
8
+ }
6
9
  function database() {
7
10
  if (!opening) opening = new Promise((resolve, reject) => {
8
11
  const request = indexedDB.open('codex-remote-history-v1', 1);
@@ -22,11 +25,76 @@
22
25
  request.onerror = () => reject(request.error);
23
26
  });
24
27
  }
25
- function release() { displayVersion++; for (const url of urls.values()) URL.revokeObjectURL(url); urls.clear(); }
28
+ function release() {
29
+ displayVersion++;
30
+ const released = urls.size;
31
+ for (const url of urls.values()) URL.revokeObjectURL(url);
32
+ urls.clear();
33
+ metric(`stage=image-cache;action=release-all;released=${released};version=${displayVersion}`);
34
+ }
35
+ function releaseUnused(value, reason = 'sync') {
36
+ const retained = new Set();
37
+ function collect(child) {
38
+ if (typeof child === 'string') { retained.add(child); return; }
39
+ if (Array.isArray(child)) { for (const item of child) collect(item); return; }
40
+ if (!child || typeof child !== 'object') return;
41
+ for (const item of Object.values(child)) collect(item);
42
+ }
43
+ collect(value);
44
+ let released = 0;
45
+ for (const [key, url] of urls) {
46
+ if (retained.has(url)) continue;
47
+ URL.revokeObjectURL(url); urls.delete(key); released++;
48
+ }
49
+ metric(`stage=image-cache;action=release-unused;reason=${reason};released=${released};retained=${urls.size}`);
50
+ }
51
+ function compactActivity(entry) {
52
+ if (!entry || entry.type !== 'activity') return entry;
53
+ if (entry.titleOnly || ['imageGeneration', 'imageView', 'lazyOperationGroup'].includes(entry.kind)) return entry;
54
+ return { type: 'activity', id: entry.id, kind: entry.kind, title: entry.title,
55
+ ...(entry.kind === 'reasoning' && entry.detail ? { detail: entry.detail } : {}),
56
+ ...(entry.kind === 'webSearch' ? { query: entry.query || '', ...(entry.action ? { action: entry.action } : {}) } : {}),
57
+ ...(entry.status ? { status: entry.status } : {}) };
58
+ }
59
+ function compactTimeline(data) {
60
+ if (!data || !Array.isArray(data.turns)) return data;
61
+ return { ...data, turns: data.turns.map(turn => {
62
+ const entries = Array.isArray(turn.entries) ? turn.entries : [];
63
+ const activities = entries.filter(entry => entry?.type === 'activity' && !entry.titleOnly);
64
+ const details = Array.isArray(turn.toolDetails) ? turn.toolDetails : [];
65
+ const byId = new Map(details.map((entry, index) => [entry?.id || `detail:${index}`, entry]));
66
+ for (const [index, entry] of activities.entries()) {
67
+ const key = entry?.id || `activity:${index}`;
68
+ if (!byId.has(key)) byId.set(key, entry);
69
+ }
70
+ return { ...turn, entries: entries.map((entry, index) => {
71
+ const compact = compactActivity(entry);
72
+ if (compact?.kind !== 'webSearch' || compact.query || compact.action) return compact;
73
+ return compactActivity(byId.get(entry?.id || `activity:${index}`) || entry);
74
+ }),
75
+ ...(byId.size ? { toolDetails: [...byId.values()] } : {}) };
76
+ }) };
77
+ }
78
+ function preserveToolDetails(data, previous) {
79
+ if (!data || !Array.isArray(data.turns) || !previous || !Array.isArray(previous.turns)) return data;
80
+ const previousTurns = new Map(compactTimeline(previous).turns.map(turn => [turn.id, turn]));
81
+ return { ...data, turns: data.turns.map(turn => {
82
+ const cached = previousTurns.get(turn.id)?.toolDetails || [];
83
+ if (!cached.length) return turn;
84
+ const current = Array.isArray(turn.toolDetails) ? turn.toolDetails : [];
85
+ const byId = new Map(cached.map((entry, index) => [entry?.id || `cached:${index}`, entry]));
86
+ for (const [index, entry] of current.entries()) {
87
+ const key = entry?.id || `current:${index}`;
88
+ if (!byId.has(key)) byId.set(key, entry);
89
+ }
90
+ return { ...turn, toolDetails: [...byId.values()] };
91
+ }) };
92
+ }
26
93
  async function get(scope, id) {
27
94
  const version = displayVersion;
28
95
  const db = await database(), record = await read(db, 'threads', JSON.stringify([scope, id]));
29
96
  if (!record) return null;
97
+ let created = 0, stale = 0;
30
98
  async function unpack(value) {
31
99
  if (version !== displayVersion) return null;
32
100
  if (value && typeof value === 'object' && value.cacheImage) {
@@ -36,30 +104,48 @@
36
104
  if (version !== displayVersion) return null;
37
105
  if (!image) throw new Error('缓存图片缺失');
38
106
  urls.set(key, URL.createObjectURL(image.blob));
107
+ created++;
39
108
  }
40
109
  return urls.get(key);
41
110
  }
111
+ // A persisted blob URL only belongs to the WebView document that created
112
+ // it. Treat older poisoned cache entries as missing so remotePath can
113
+ // reload the image instead of rendering a permanently broken URL.
114
+ if (typeof value === 'string' && value.startsWith('blob:')) { stale++; return ''; }
42
115
  if (Array.isArray(value)) { const result = []; for (const item of value) result.push(await unpack(item)); return result; }
43
116
  if (value && typeof value === 'object') { const result = {}; for (const [k, v] of Object.entries(value)) result[k] = await unpack(v); return result; }
44
117
  return value;
45
118
  }
46
- const data = await unpack(record.data);
47
- return version === displayVersion ? { ...data, cached: true, nextCursor: null } : null;
119
+ const data = compactTimeline(await unpack(record.data));
120
+ metric(`stage=image-cache;action=read;created=${created};stale=${stale};active=${urls.size}`);
121
+ return version === displayVersion ? { ...data, cached: true } : null;
48
122
  }
49
123
  function save(scope, data, hash, id = data.thread.id) {
50
124
  // Snapshot at scheduling time; live events may mutate the UI model while hashing.
51
- const snapshot = structuredClone({ ...data, turns: data.turns.slice(-40) });
125
+ let snapshot = structuredClone(compactTimeline({ ...data, turns: data.turns.slice(-40) }));
52
126
  const key = JSON.stringify([scope, id]);
53
127
  const work = writes.then(async () => {
128
+ let outcome = { retained: true, evictedThreads: 0, size: 0 };
54
129
  const db = await database(), images = new Map(), byUrl = new Map(), referenced = new Set(), previousImages = new Map();
130
+ const objectUrlKeys = new Map([...urls].map(([imageKey, url]) => [url, imageKey]));
55
131
  const previous = await read(db, 'threads', key);
132
+ snapshot = preserveToolDetails(snapshot, previous?.data);
56
133
  function collect(value) {
57
134
  if (!value || typeof value !== 'object') return;
58
- if (value.remotePath && value.url?.cacheImage) previousImages.set(value.remotePath, value.url.cacheImage);
135
+ if (value.remotePath && (value.url?.cacheImage || value.thumbnailUrl?.cacheImage)) {
136
+ previousImages.set(value.remotePath, {
137
+ original: value.url?.cacheImage || '', thumbnail: value.thumbnailUrl?.cacheImage || '',
138
+ });
139
+ }
59
140
  for (const child of Object.values(value)) collect(child);
60
141
  }
61
142
  collect(previous?.data);
62
143
  async function pack(value) {
144
+ if (value && typeof value === 'object' && value.cacheImage) { referenced.add(value.cacheImage); return value; }
145
+ if (typeof value === 'string' && objectUrlKeys.has(value)) {
146
+ const imageKey = objectUrlKeys.get(value); referenced.add(imageKey); return { cacheImage: imageKey };
147
+ }
148
+ if (typeof value === 'string' && value.startsWith('blob:')) return '';
63
149
  if (typeof value === 'string' && value.startsWith('data:image/')) {
64
150
  if (!byUrl.has(value)) byUrl.set(value, (async () => {
65
151
  const key = JSON.stringify([scope, await hash(value)]);
@@ -73,16 +159,19 @@
73
159
  }
74
160
  if (Array.isArray(value)) return Promise.all(value.map(pack));
75
161
  if (value && typeof value === 'object') {
76
- if (value.remotePath && !value.url && previousImages.has(value.remotePath)) {
77
- const key = previousImages.get(value.remotePath); referenced.add(key);
78
- return { ...value, available: true, url: { cacheImage: key }, thumbnailUrl: { cacheImage: key } };
162
+ let source = value;
163
+ if (value.remotePath && previousImages.has(value.remotePath)) {
164
+ const cached = previousImages.get(value.remotePath);
165
+ if (!value.url && cached.original) { referenced.add(cached.original); source = { ...source, available: true, url: { cacheImage: cached.original } }; }
166
+ if (!value.thumbnailUrl && cached.thumbnail) { referenced.add(cached.thumbnail); source = { ...source, available: true, thumbnailUrl: { cacheImage: cached.thumbnail } }; }
79
167
  }
80
- return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await pack(v)])));
168
+ return Object.fromEntries(await Promise.all(Object.entries(source).map(async ([k, v]) => [k, await pack(v)])));
81
169
  }
82
170
  return value;
83
171
  }
84
172
  const packed = await pack(snapshot);
85
173
  const record = { key, data: packed, at: Date.now(), imageKeys: [...referenced], size: new TextEncoder().encode(JSON.stringify(packed)).length };
174
+ outcome.size = record.size;
86
175
  await new Promise((resolve, reject) => {
87
176
  const tx = db.transaction(['threads', 'images', 'blobs'], 'readwrite');
88
177
  tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error || new Error('缓存写入中止'));
@@ -90,30 +179,35 @@
90
179
  threads.put(record);
91
180
  for (const image of images.values()) { metadata.put({ key: image.key, size: image.size }); blobs.put({ key: image.key, blob: image.blob }); }
92
181
  const allThreads = threads.getAll(), allImages = metadata.getAll();
93
- let records, sizes;
182
+ let records, sizes, pruned = false;
94
183
  function prune() {
95
- if (!records || !sizes) return;
184
+ if (!records || !sizes || pruned) return;
185
+ pruned = true;
96
186
  records.sort((a, b) => b.at - a.at);
97
- const keep = new Set(); let bytes = 0;
187
+ const keep = new Set(), keptThreads = new Set(); let bytes = 0;
98
188
  for (const [index, item] of records.entries()) {
99
- const added = item.size + item.imageKeys.reduce((sum, image) => sum + (keep.has(image) ? 0 : sizes.get(image) || 0), 0);
100
- if (index >= 20 || bytes + added > limit) { threads.delete(item.key); continue; }
101
- bytes += added; for (const image of item.imageKeys) keep.add(image);
189
+ const imageKeys = item.imageKeys || [];
190
+ const added = item.size + imageKeys.reduce((sum, image) => sum + (keep.has(image) ? 0 : sizes.get(image) || 0), 0);
191
+ if (index >= 20 || bytes + added > limit) { threads.delete(item.key); outcome.evictedThreads++; continue; }
192
+ keptThreads.add(item.key); bytes += added; for (const image of imageKeys) keep.add(image);
102
193
  }
194
+ outcome.retained = keptThreads.has(key);
103
195
  for (const image of sizes.keys()) if (!keep.has(image)) { metadata.delete(image); blobs.delete(image); }
104
196
  }
105
197
  allThreads.onsuccess = () => { records = allThreads.result; prune(); };
106
198
  allImages.onsuccess = () => { sizes = new Map(allImages.result.map(image => [image.key, image.size])); prune(); };
107
199
  });
200
+ return outcome;
108
201
  });
109
202
  writes = work.catch(() => {});
110
203
  return work;
111
204
  }
112
- async function getImage(scope, id, path) {
205
+ async function getImage(scope, id, path, variant = 'original') {
113
206
  const db = await database(), record = await read(db, 'threads', JSON.stringify([scope, id]));
114
207
  function find(value) {
115
208
  if (!value || typeof value !== 'object') return null;
116
- if (value.remotePath === path && value.url?.cacheImage) return value.url.cacheImage;
209
+ const source = variant === 'thumbnail' ? value.thumbnailUrl : value.url;
210
+ if (value.remotePath === path && source?.cacheImage) return source.cacheImage;
117
211
  for (const child of Object.values(value)) { const key = find(child); if (key) return key; }
118
212
  return null;
119
213
  }
@@ -142,5 +236,5 @@
142
236
  const record = records.filter(item => JSON.parse(item.key)[0] === scope).sort((a, b) => b.at - a.at)[0];
143
237
  return record ? get(scope, JSON.parse(record.key)[1]) : null;
144
238
  }
145
- window.codexHistoryCache = { get, getImage, save, latest, clear, release };
239
+ window.codexHistoryCache = { get, getImage, save, latest, clear, release, releaseUnused };
146
240
  })();
@@ -0,0 +1,167 @@
1
+ (function (root, factory) {
2
+ const create = factory(root);
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createHistoryController = create;
5
+ })(globalThis, function (root) {
6
+ function createHistoryController(options) {
7
+ const { state, transport, api, isThreadRunning, report, onCacheError } = options;
8
+ let saveTimer, readVersion = 0, writeVersion = 0;
9
+
10
+ function updateCache(patch) {
11
+ state.historyCache = { ...state.historyCache, ...patch, changedAt: Date.now() };
12
+ }
13
+
14
+ function metric(action, fields) {
15
+ report(`stage=cache;action=${action};${Object.entries(fields).map(([key, value]) => `${key}=${value}`).join(';')}`);
16
+ }
17
+
18
+ async function read(id, thread, reason = 'select') {
19
+ const version = ++readVersion;
20
+ const started = performance.now();
21
+ if (!transport.has('historyCache')) {
22
+ updateCache({ threadId: id, read: 'unavailable', readMs: 0, turns: 0, error: '' });
23
+ metric('read', { reason, result: 'unavailable', ms: 0, turns: 0 });
24
+ return null;
25
+ }
26
+ updateCache({ threadId: id, read: 'loading', readMs: 0, turns: 0, error: '' });
27
+ try {
28
+ const cached = await transport.readCached(id, thread);
29
+ const ms = Math.round(performance.now() - started), turns = cached?.turns?.length || 0;
30
+ if (version === readVersion) updateCache({ threadId: id, read: cached ? 'hit' : 'miss', readMs: ms, turns, error: '' });
31
+ metric('read', { reason, result: cached ? 'hit' : 'miss', ms, turns });
32
+ return cached || null;
33
+ } catch (error) {
34
+ const ms = Math.round(performance.now() - started);
35
+ if (version === readVersion) updateCache({ threadId: id, read: 'error', readMs: ms, turns: 0, error: error.message });
36
+ metric('read', { reason, result: 'error', ms, turns: 0 });
37
+ onCacheError(error);
38
+ return null;
39
+ }
40
+ }
41
+
42
+ async function readLatest(reason = 'startup') {
43
+ const version = ++readVersion;
44
+ const started = performance.now();
45
+ if (!transport.has('historyCache')) {
46
+ updateCache({ threadId: '', read: 'unavailable', readMs: 0, turns: 0, error: '' });
47
+ metric('read', { reason, result: 'unavailable', ms: 0, turns: 0 });
48
+ return null;
49
+ }
50
+ updateCache({ threadId: '', read: 'loading', readMs: 0, turns: 0, error: '' });
51
+ try {
52
+ const cached = await transport.readLastCached();
53
+ const ms = Math.round(performance.now() - started), turns = cached?.turns?.length || 0;
54
+ if (version === readVersion) updateCache({ threadId: cached?.thread?.id || '', read: cached ? 'hit' : 'miss', readMs: ms, turns, error: '' });
55
+ metric('read', { reason, result: cached ? 'hit' : 'miss', ms, turns });
56
+ return cached || null;
57
+ } catch (error) {
58
+ const ms = Math.round(performance.now() - started);
59
+ if (version === readVersion) updateCache({ threadId: '', read: 'error', readMs: ms, turns: 0, error: error.message });
60
+ metric('read', { reason, result: 'error', ms, turns: 0 });
61
+ onCacheError(error);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function cancelSave() {
67
+ clearTimeout(saveTimer);
68
+ saveTimer = undefined;
69
+ }
70
+
71
+ async function saveNow(timeline, thread, reason = 'sync') {
72
+ if (!transport.has('historyCache') || !timeline || timeline.cached || !thread) return false;
73
+ const version = ++writeVersion;
74
+ const started = performance.now();
75
+ updateCache({ threadId: thread.id, write: 'writing', writeMs: 0, error: '' });
76
+ try {
77
+ const outcome = await transport.saveCached({ ...timeline, thread });
78
+ const ms = Math.round(performance.now() - started);
79
+ if (version === writeVersion) updateCache({ threadId: thread.id, write: 'saved', writeMs: ms,
80
+ retained: outcome?.retained !== false, evicted: Number(outcome?.evictedThreads) || 0, error: '' });
81
+ metric('write', { reason, result: 'saved', ms, retained: outcome?.retained === false ? 0 : 1,
82
+ evicted: Number(outcome?.evictedThreads) || 0 });
83
+ return true;
84
+ } catch (error) {
85
+ const ms = Math.round(performance.now() - started);
86
+ if (version === writeVersion) updateCache({ threadId: thread.id, write: 'error', writeMs: ms, error: error.message });
87
+ metric('write', { reason, result: 'error', ms, retained: 0, evicted: 0 });
88
+ onCacheError(error);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function scheduleSave(timeline, thread, historyLoading) {
94
+ if (!transport.has('historyCache') || !timeline || timeline.cached || historyLoading || !thread) return;
95
+ cancelSave();
96
+ const snapshot = { ...timeline, thread };
97
+ saveTimer = setTimeout(() => {
98
+ saveTimer = undefined;
99
+ void saveNow(snapshot, thread, 'settled');
100
+ }, 500);
101
+ }
102
+
103
+ function release() {
104
+ root.codexHistoryCache?.release();
105
+ }
106
+
107
+ function releaseUnused(timeline, reason) {
108
+ root.codexHistoryCache?.releaseUnused?.(timeline, reason);
109
+ }
110
+
111
+ async function recover(id, previous, isCurrent, hostId = '') {
112
+ const previousTurns = previous?.thread?.id === id && Array.isArray(previous.turns) ? previous.turns : [];
113
+ const listed = state.threads.find(thread => thread.id === id);
114
+ const previousUpdatedAt = Number(previous?.thread?.updatedAt) || 0;
115
+ const latestCached = previousTurns.at(-1);
116
+ const listConfirmsCache = transport.has('incrementalEvents') && transport.transportMode() !== 'relay'
117
+ && listed && !isThreadRunning(listed) && Number(listed.updatedAt) > 0
118
+ && Number(listed.updatedAt) === previousUpdatedAt
119
+ && latestCached?.id && latestCached.status !== 'inProgress';
120
+ if (listConfirmsCache) {
121
+ return { timeline: { ...previous, thread: { ...previous.thread, ...listed }, cached: false },
122
+ unchanged: true, partial: false, listTrusted: true };
123
+ }
124
+ const query = new URLSearchParams({ turnLimit: '10' });
125
+ if (hostId) query.set('hostId', hostId);
126
+ const previousLatest = previousTurns.at(-1);
127
+ if (previousLatest?.id) {
128
+ if (previousUpdatedAt > 0) query.set('knownUpdatedAt', previousUpdatedAt.toString());
129
+ query.set('knownTurnId', previousLatest.id);
130
+ if (previousLatest.status) query.set('knownTurnStatus', previousLatest.status);
131
+ if (previous.historyView) query.set('knownHistoryView', previous.historyView);
132
+ }
133
+ const data = await api(`/api/threads/${encodeURIComponent(id)}/timeline?${query}`);
134
+ if (!isCurrent()) return null;
135
+ if (data.notModified === true) {
136
+ const { notModified, ...fresh } = data;
137
+ return { timeline: { ...previous, ...fresh, thread: { ...previous.thread, ...data.thread },
138
+ turns: previousTurns, nextCursor: previous.nextCursor, cached: false }, unchanged: true };
139
+ }
140
+ let partial = false;
141
+ if (data.partialFromTurnId) {
142
+ const start = previousTurns.findIndex(turn => turn.id === data.partialFromTurnId);
143
+ if (start >= 0) {
144
+ data.turns = [...previousTurns.slice(0, start), ...data.turns];
145
+ if (Object.prototype.hasOwnProperty.call(previous, 'nextCursor')) data.nextCursor = previous.nextCursor;
146
+ partial = true;
147
+ }
148
+ delete data.partialFromTurnId;
149
+ }
150
+ const freshIds = new Set(data.turns.map(turn => turn.id));
151
+ const firstOverlap = previousTurns.findIndex(turn => freshIds.has(turn.id));
152
+ if (firstOverlap >= 0) {
153
+ data.turns.unshift(...previousTurns.slice(0, firstOverlap).filter(turn => !freshIds.has(turn.id)));
154
+ if (Object.prototype.hasOwnProperty.call(previous, 'nextCursor')) data.nextCursor = previous.nextCursor;
155
+ if (previous.historyGapBeforeTurnId) data.historyGapBeforeTurnId = previous.historyGapBeforeTurnId;
156
+ } else if (previousTurns.length && data.turns.length && data.nextCursor) {
157
+ data.turns = [...previousTurns, ...data.turns];
158
+ data.historyGapBeforeTurnId = data.turns[previousTurns.length]?.id || '';
159
+ }
160
+ return { timeline: data, unchanged: false, partial };
161
+ }
162
+
163
+ return Object.freeze({ read, readLatest, saveNow, scheduleSave, cancelSave, release, releaseUnused, recover });
164
+ }
165
+
166
+ return createHistoryController;
167
+ });