@walkhi/code-relax 0.1.0-beta.26 → 0.1.0-beta.28

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 +80 -67
  2. package/dist/bin/codex-remote.mjs +286 -239
  3. package/dist/shared/app-server-events.cjs +102 -16
  4. package/dist/shared/code-browser.cjs +159 -0
  5. package/dist/shared/file-changes.cjs +31 -0
  6. package/dist/shared/file-citations.cjs +117 -0
  7. package/dist/shared/file-mentions.cjs +80 -35
  8. package/dist/shared/message-delivery.cjs +38 -0
  9. package/dist/shared/p2p-file-channel.cjs +74 -0
  10. package/dist/shared/server-requests.cjs +149 -41
  11. package/dist/shared/service-endpoints.cjs +11 -11
  12. package/dist/shared/timeline-sync.cjs +173 -156
  13. package/dist/skills/code-relax/SKILL.md +2 -1
  14. package/dist/src/bounded-process-log.mjs +63 -0
  15. package/dist/src/celsius-client.mjs +107 -0
  16. package/dist/src/code-browser.mjs +101 -0
  17. package/dist/src/desktop-launcher.mjs +30 -30
  18. package/dist/src/dots-approval-timeline.mjs +99 -0
  19. package/dist/src/dots-avatar.mjs +42 -0
  20. package/dist/src/dots-chat-projection.mjs +50 -0
  21. package/dist/src/dots-chat-server.mjs +412 -0
  22. package/dist/src/dots-message-images.mjs +319 -0
  23. package/dist/src/dots-profile.mjs +77 -0
  24. package/dist/src/dots-room-activity.mjs +94 -0
  25. package/dist/src/dots-room-client.mjs +135 -0
  26. package/dist/src/dots-room-projection.mjs +94 -0
  27. package/dist/src/dots-schedules.mjs +66 -0
  28. package/dist/src/dots-summary-tasks.mjs +32 -0
  29. package/dist/src/dots-task-catalog.mjs +411 -0
  30. package/dist/src/file-transfer-range.mjs +22 -0
  31. package/dist/src/file-upload-session.mjs +130 -124
  32. package/dist/src/hosted-history-app-server.mjs +103 -0
  33. package/dist/src/hosted-history-cache.mjs +113 -0
  34. package/dist/src/lan-socket-server.mjs +205 -160
  35. package/dist/src/managed-app-server.mjs +241 -238
  36. package/dist/src/message-upload-files.mjs +74 -0
  37. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  38. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  39. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  40. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  41. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  42. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  43. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  44. package/dist/src/platform/windows/project-locations.mjs +16 -0
  45. package/dist/src/project-create.mjs +119 -0
  46. package/dist/src/project-management.mjs +69 -0
  47. package/dist/src/self-relay/account-connections.mjs +52 -0
  48. package/dist/src/self-relay/admin-page.mjs +80 -80
  49. package/dist/src/self-relay/admin-style.mjs +14 -9
  50. package/dist/src/self-relay/connector.mjs +22 -4
  51. package/dist/src/self-relay/file-wire.mjs +13 -0
  52. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  53. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  54. package/dist/src/self-relay/server.mjs +894 -849
  55. package/dist/src/server.mjs +463 -148
  56. package/dist/src/service-doctor.mjs +72 -61
  57. package/dist/src/service-lifecycle.mjs +51 -43
  58. package/dist/src/shared-app-server.mjs +166 -107
  59. package/dist/src/shared-catalog.mjs +132 -40
  60. package/dist/src/shared-thread-stream.mjs +103 -25
  61. package/dist/src/usage-reset.mjs +17 -0
  62. package/dist/src/workstation-downloads.mjs +120 -0
  63. package/package.json +1 -1
@@ -0,0 +1,412 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readMessageUploads } from './message-upload-files.mjs';
3
+ import { HostedHistoryAppServer } from './hosted-history-app-server.mjs';
4
+ import { historyCacheScope } from './hosted-history-cache.mjs';
5
+ import { DotsRoomClient } from './dots-room-client.mjs';
6
+ import { DotsRoomActivity } from './dots-room-activity.mjs';
7
+ import { DotsApprovalTimeline } from './dots-approval-timeline.mjs';
8
+ import { dotsRoomTurns } from './dots-room-projection.mjs';
9
+ import { dotsChatRevision, dotsChatMessageRevision, projectDotsChatItem } from './dots-chat-projection.mjs';
10
+ import timelineSync from '../shared/timeline-sync.cjs';
11
+
12
+ const pageSize = 20;
13
+ const mergeTurns = (older, newer) => timelineSync.mergeOrderedEntries(older, newer, (old, fresh) => ({ ...fresh,
14
+ items: [...fresh.items, ...old.items.filter(item => !fresh.deleted && item.type === 'approval' && !fresh.items.some(next => next.id === item.id))] }));
15
+ function connectedPrefix(previous, incoming) {
16
+ const old = previous.filter(turn => turn.roomMessage), ids = new Set(incoming.map(turn => turn.id));
17
+ const boundary = old.findIndex(turn => ids.has(turn.id));
18
+ return boundary >= 0 && old.slice(boundary).every(turn => ids.has(turn.id));
19
+ }
20
+
21
+ // Desktop 26.1002.7124.0: room messages are authoritative chat history.
22
+ // The execution root remains the UI identity and owns tasks/approvals only.
23
+ export class DotsChatServer extends HostedHistoryAppServer {
24
+ constructor(transport, roomClient = new DotsRoomClient(transport), { uploadsRoot } = {}) {
25
+ super(transport.localServer.url, transport.historyCache);
26
+ this.transport = transport; this.rooms = roomClient; this.uploadsRoot = uploadsRoot;
27
+ this.serverRequests = transport.serverRequests;
28
+ this.historyHeadKey = 'messaging-room-v1';
29
+ this.windowLoads = new Map(); this.historyLoads = new Map(); this.bindings = new Map();
30
+ this.observedThreads = new Set(); this.reconnectAttempts = 0;
31
+ this.approvalTimeline = new DotsApprovalTimeline(this);
32
+ this.chatActivity = new DotsRoomActivity(this);
33
+ this.chatActivity.on('unavailable', error => this.emit('activity-error', error));
34
+ this.rooms.on('message', (topic, value) => this.roomNotification(topic, value));
35
+ this.rooms.on('disconnected', reason => { this.invalidate(reason); this.scheduleReconnect(); });
36
+ this.hostDisconnected = () => { this.rooms.close(); this.invalidate({ source: 'hosted-app-server' }); };
37
+ this.hostConnected = () => { void this.restoreObservers().catch(error => this.emit('subscription-error', error)); };
38
+ this.hostNotification = ({ method, params }) => this.executionNotification(method, params);
39
+ this.hostRequestsChanged = id => {
40
+ this.emit('requests-changed', id);
41
+ const observed = structuredClone(this.serverRequests.history(id));
42
+ const targets = new Set(this.approvalTimeline.targets(id));
43
+ if (transport.threadSources.get(id) === 'aeon') targets.add(id);
44
+ for (const threadId of targets) if (observed.length) {
45
+ void this.approvalTimeline.observe(threadId, observed, this.threads.get(threadId)?.thread.turns)
46
+ .then(() => this.applyApprovalWindow(threadId)).catch(error => this.emit('subscription-error', error));
47
+ }
48
+ };
49
+ transport.on('disconnected', this.hostDisconnected); transport.on('connected', this.hostConnected);
50
+ transport.on('host-notification', this.hostNotification); transport.on('requests-changed', this.hostRequestsChanged);
51
+ }
52
+
53
+ get ready() { return this.transport?.ready === true && this.rooms?.ready === true; }
54
+ get historyIdentity() { return this.transport.profileIdentity; }
55
+ historyScope(threadId) {
56
+ const roomId = this.bindings.get(threadId)?.id;
57
+ return roomId ? historyCacheScope(this.historyIdentity, 'aeon', `${threadId}:room:${roomId}`) : null;
58
+ }
59
+ async restoreHistoryHead(threadId) {
60
+ const scope = this.historyScope(threadId);
61
+ // Cache files are atomically replaced; this read need not wait behind
62
+ // another thread's queued writes. It never establishes live readiness.
63
+ const saved = scope && await this.historyCache?.record(scope);
64
+ if (scope !== this.historyScope(threadId)) throw new Error('点点账号已变化。');
65
+ return saved?.entries?.[this.historyHeadKey]?.value || null;
66
+ }
67
+ async connect() {
68
+ await this.transport.connect();
69
+ const identity = JSON.stringify(this.historyIdentity);
70
+ if (this.identity && this.identity !== identity) {
71
+ this.rooms.close(); this.invalidate(); this.threads.clear(); this.bindings.clear(); this.observedThreads.clear();
72
+ }
73
+ this.identity = identity;
74
+ await this.rooms.connect();
75
+ }
76
+ invalidate(reason = { source: 'identity' }) {
77
+ this.traceSync('invalidated', null, reason);
78
+ clearTimeout(this.reconnectTimer);
79
+ this.epoch = randomUUID(); this.chatActivity.close(); this.approvalTimeline.clear();
80
+ for (const record of this.threads.values()) { record.needsReload = true; record.tokens.clear(); record.refresh = null; }
81
+ for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
82
+ for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
83
+ this.windowLoads.clear(); this.historyLoads.clear(); this.emit('disconnected');
84
+ }
85
+ scheduleReconnect() {
86
+ if (this.closed || !this.transport.ready || !this.observedThreads.size || this.reconnectAttempts >= 5) return;
87
+ clearTimeout(this.reconnectTimer);
88
+ this.reconnectTimer = setTimeout(() => {
89
+ this.reconnectTimer = null;
90
+ void this.restoreObservers().catch(error => { this.emit('subscription-error', error); this.scheduleReconnect(); });
91
+ }, Math.min(30000, 1000 * 2 ** this.reconnectAttempts++));
92
+ this.reconnectTimer.unref();
93
+ }
94
+ async restoreObservers() {
95
+ if (!this.observedThreads.size || this.closed) return;
96
+ await this.connect();
97
+ for (const id of this.observedThreads) await this.timeline(id);
98
+ this.reconnectAttempts = 0;
99
+ }
100
+ traceSync(stage, threadId, detail = {}) {
101
+ console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-sync', pid: process.pid,
102
+ epoch: this.epoch, stage, threadId, ...detail }));
103
+ }
104
+ async room(threadId) {
105
+ const epoch = this.epoch;
106
+ const id = await this.rooms.roomId(threadId), room = await this.rooms.room(id);
107
+ if (epoch !== this.epoch) throw new Error('点点连接已变化,请重新同步。');
108
+ this.bindings.set(threadId, room); return room;
109
+ }
110
+ thread(threadId, turns, previous = {}) {
111
+ const messages = turns.filter(turn => turn.roomMessage), last = messages.at(-1);
112
+ const updatedAt = Math.max(Number(previous.updatedAt) || 0, ...messages.map(turn =>
113
+ Math.floor(Date.parse(turn.updatedAt || turn.createdAt) / 1000) || 0));
114
+ return { ...previous, id: threadId, hostId: 'durable', threadSource: 'aeon',
115
+ name: this.transport.profileName(threadId), avatar: this.transport.profileAvatar(threadId),
116
+ color: this.transport.profileColor(threadId), status: turns.some(turn => turn.status === 'inProgress') ? 'active' : 'idle',
117
+ messagingRoomId: this.bindings.get(threadId)?.id, lastMessageAt: last?.createdAt,
118
+ updatedAt,
119
+ chatRevision: dotsChatRevision(turns), chatMessageRevision: dotsChatMessageRevision(messages), turns };
120
+ }
121
+ async latestChatState(threadId) {
122
+ await this.timeline(threadId);
123
+ const { chatRevision, chatMessageRevision, updatedAt } = this.threads.get(threadId).thread;
124
+ return { chatRevision, chatMessageRevision, updatedAt };
125
+ }
126
+ prepare(threadId) { return this.timeline(threadId); }
127
+ historyCursor(threadId, _turn, cursor) {
128
+ return cursor ? JSON.stringify({ kind: 'dots-room-messages', threadId, roomId: this.bindings.get(threadId)?.id, before: cursor }) : null;
129
+ }
130
+ snapshot(threadId, record, notModified = false) {
131
+ // Catalog and chat must use the same current profile; a reused history
132
+ // window must not restore its earlier fallback name or empty avatar.
133
+ record.thread = { ...record.thread, name: this.transport.profileName(threadId),
134
+ avatar: this.transport.profileAvatar(threadId), color: this.transport.profileColor(threadId) };
135
+ const id = randomUUID();
136
+ record.tokens.set(id, { sequence: record.sequence, expires: Date.now() + 30000 });
137
+ this.scheduleCleanup(threadId, this.slot(threadId));
138
+ return { thread: notModified ? { ...record.thread, turns: [] } : record.thread,
139
+ ...(notModified ? { notModified: true } : {}),
140
+ nextCursor: this.historyCursor(threadId, null, record.nextCursor), sync: { id, epoch: this.epoch, seq: record.sequence } };
141
+ }
142
+ async timeline(threadId, options = {}, knownSync) {
143
+ const started = Date.now();
144
+ await this.connect();
145
+ const connectedAt = Date.now();
146
+ const epoch = this.epoch, force = options.reconcileObserved === true;
147
+ const observed = this.threads.get(threadId);
148
+ const retained = !force && observed && !observed.needsReload && !observed.hydrating;
149
+ let load = this.windowLoads.get(threadId);
150
+ if (!load || force && !load.force) {
151
+ const previous = load;
152
+ load = { force, work: previous ? previous.work.then(() => this.loadWindow(threadId, force)) : this.loadWindow(threadId, force) };
153
+ this.windowLoads.set(threadId, load);
154
+ }
155
+ try {
156
+ await load.work;
157
+ if (epoch !== this.epoch) throw new Error('点点连接已变化,请重新同步。');
158
+ const record = this.threads.get(threadId);
159
+ if (record.refresh) await record.refresh;
160
+ if (epoch !== this.epoch || this.threads.get(threadId) !== record) throw new Error('点点连接已变化,请重新同步。');
161
+ // The room remains observed while the phone is away. Its epoch/sequence
162
+ // identifies the body; the expiring token only owns snapshot-to-stream handoff.
163
+ const same = !force && (this.matchesUpdatedAt(record.thread, knownSync?.updatedAt)
164
+ || Boolean(retained && record === observed && knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
165
+ && record.sequence === knownSync.seq));
166
+ this.traceSync('window-ready', threadId, { connectMs: connectedAt - started,
167
+ readMs: Date.now() - connectedAt, retained: Boolean(retained), notModified: same,
168
+ sequence: record.sequence, knownSequence: knownSync?.seq ?? null, turns: record.thread.turns.length });
169
+ if (!same && retained && record === observed && knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
170
+ && knownSync.seq >= record.recoverAfter && knownSync.seq < record.sequence) {
171
+ const turns = [...record.changes.values()].filter(change => change.sequence > knownSync.seq).map(change => change.turn);
172
+ return { thread: { ...record.thread, turns }, incremental: true,
173
+ sync: { id: knownSync.id, epoch: this.epoch, fromSeq: knownSync.seq + 1, toSeq: record.sequence } };
174
+ }
175
+ return this.snapshot(threadId, record, same);
176
+ } finally { if (this.windowLoads.get(threadId) === load) this.windowLoads.delete(threadId); }
177
+ }
178
+ async loadWindow(threadId, force) {
179
+ const epoch = this.epoch, slot = this.slot(threadId); slot.readers++;
180
+ const work = slot.tail.then(async () => {
181
+ const current = () => { if (epoch !== this.epoch) throw new Error('点点连接已变化,请重新同步。'); };
182
+ current();
183
+ let record = this.threads.get(threadId);
184
+ if (!force && record && !record.needsReload && !record.hydrating) return;
185
+ if (record?.refresh) { await record.refresh.catch(() => {}); current(); }
186
+ const room = await this.room(threadId); current();
187
+ if (record && record.thread.messagingRoomId !== room.id) record = null;
188
+ record ||= { thread: this.thread(threadId, []), sequence: 0, recoverAfter: 0,
189
+ changes: new Map(), changeBytes: 0, tokens: new Map(), dirty: 0, updates: new Set() };
190
+ record.hydrating = true; record.needsReload = true;
191
+ this.threads.set(threadId, record); this.observedThreads.add(threadId);
192
+ try {
193
+ const [saved, page] = await Promise.all([this.restoreHistoryHead(threadId), this.rooms.messages(room.id)]); current();
194
+ if (!page.items.length && (room.room_messaging_state === 'has_messages_sent'
195
+ || record.thread.turns.some(turn => turn.items.length) || saved?.turns?.some(turn => turn.items.length))) {
196
+ throw new Error('点点最新消息尚未确认,请稍后重新同步。');
197
+ }
198
+ const incoming = dotsRoomTurns(room, page.items), observed = record.thread.turns.length ? record.thread.turns : saved?.turns || [];
199
+ const overlap = connectedPrefix(observed, incoming);
200
+ const turns = overlap ? mergeTurns(observed, incoming) : incoming;
201
+ const previousCursor = record.thread.turns.length ? record.nextCursor : saved?.nextCursor;
202
+ record.thread = this.thread(threadId, turns, record.thread);
203
+ record.nextCursor = overlap && previousCursor !== undefined ? previousCursor : page.before;
204
+ record.hydrating = false; record.needsReload = false;
205
+ record.recoverAfter = ++record.sequence; record.tokens.clear(); record.changes.clear(); record.changeBytes = 0;
206
+ if (record.dirty || record.updates.size) await this.refreshMessages(threadId, record);
207
+ current(); this.trimWindow(record);
208
+ this.emit('timeline-reset', this.snapshot(threadId, record));
209
+ void this.saveHistoryWindow(threadId, true)?.catch(error => this.emit('subscription-error', error));
210
+ void this.transport.watchExecution(threadId, 'aeon')
211
+ .catch(error => this.emit('subscription-error', error));
212
+ } catch (error) { record.hydrating = false; record.needsReload = true; throw error; }
213
+ }).finally(() => { slot.readers--; });
214
+ slot.tail = work.catch(() => {}); return work;
215
+ }
216
+ firstTime(turns) {
217
+ const times = turns.flatMap(turn => turn.items.map(item => item.startedAtMs)).filter(Number.isFinite);
218
+ return times.length ? Math.min(...times) : Infinity;
219
+ }
220
+ applyApprovalWindow(threadId) {
221
+ const record = this.threads.get(threadId);
222
+ if (!record || record.hydrating || record.needsReload) return;
223
+ this.publish(threadId, record, this.approvalTimeline.overlay(threadId, record.thread.turns, { fromMs: this.firstTime(record.thread.turns) }));
224
+ }
225
+ publish(threadId, record, turns) {
226
+ const changed = turns.filter(turn => JSON.stringify(turn) !== JSON.stringify(record.thread.turns.find(old => old.id === turn.id)));
227
+ if (!changed.length) return;
228
+ record.thread = this.thread(threadId, turns, record.thread);
229
+ this.publishChanges(threadId, record, changed);
230
+ }
231
+ publishChanges(threadId, record, changed) {
232
+ record.thread.updatedAt = Math.max(Number(record.thread.updatedAt) || 0, ...changed.map(turn =>
233
+ Math.floor(Date.parse(turn.updatedAt || turn.createdAt) / 1000) || 0));
234
+ for (const turn of changed) {
235
+ const sequence = ++record.sequence;
236
+ record.changes.set(turn.id, { turn, sequence });
237
+ record.changeBytes += Buffer.byteLength(JSON.stringify(turn));
238
+ if (record.changeBytes > 8 * 1024 * 1024) { record.recoverAfter = sequence; record.changes.clear(); record.changeBytes = 0; }
239
+ this.emit('turn', { thread: record.thread, turn, sequence, immediate: true });
240
+ }
241
+ this.emit('notification', { method: 'thread/status/changed', params: { threadId, status: record.thread.status,
242
+ updatedAt: record.thread.updatedAt, chatRevision: record.thread.chatRevision, chatMessageRevision: record.thread.chatMessageRevision } });
243
+ this.saveHistoryWindow(threadId);
244
+ }
245
+ trimWindow(record) {
246
+ if (record.thread.turns.length <= 40) return;
247
+ const turns = record.thread.turns.slice(-40), first = turns.find(turn => turn.roomMessage);
248
+ if (!first) return;
249
+ record.thread = this.thread(record.thread.id, turns, record.thread);
250
+ record.nextCursor = first.id; record.recoverAfter = record.sequence;
251
+ record.changes.clear(); record.changeBytes = 0;
252
+ }
253
+ roomNotification(topic, event) {
254
+ if (topic !== 'calpico-chatgpt-messaging') return;
255
+ const payload = event?.payload, roomId = payload?.room_id || payload?.room?.id;
256
+ for (const [threadId, room] of this.bindings) {
257
+ if (room.id !== roomId) continue;
258
+ const record = this.threads.get(threadId);
259
+ if (!record) continue;
260
+ if (event.type === 'calpico-message-add') {
261
+ record.dirty++;
262
+ if (typeof payload.message?.id === 'string') record.updates.add(payload.message.id);
263
+ }
264
+ else if (event.type === 'calpico-message-update' && typeof payload.message_id === 'string') record.updates.add(payload.message_id);
265
+ else if (event.type === 'calpico-room-leave') {
266
+ record.needsReload = true; this.emit('history-invalidated', threadId); return;
267
+ } else if (event.type === 'calpico-room-update' || event.type === 'calpico-room-metadata-update') {
268
+ if (!record.roomRefresh) {
269
+ const epoch = this.epoch;
270
+ record.roomRefresh = this.rooms.room(roomId).then(next => {
271
+ if (epoch === this.epoch && this.threads.get(threadId) === record) this.bindings.set(threadId, next);
272
+ }).catch(error => this.emit('subscription-error', error)).finally(() => { record.roomRefresh = null; });
273
+ }
274
+ return;
275
+ } else continue;
276
+ if (!record.hydrating && !record.needsReload) void this.refreshMessages(threadId, record)
277
+ .catch(error => { if (this.threads.get(threadId) !== record) return;
278
+ record.needsReload = true; this.emit('subscription-error', error); this.emit('history-invalidated', threadId); });
279
+ }
280
+ }
281
+ async refreshMessages(threadId, record) {
282
+ if (record.refresh) return record.refresh;
283
+ const epoch = this.epoch, room = this.bindings.get(threadId);
284
+ const check = () => { if (epoch !== this.epoch || this.threads.get(threadId) !== record) throw new Error('点点连接已变化。'); };
285
+ const work = (async () => {
286
+ do {
287
+ const dirty = record.dirty; record.dirty = 0;
288
+ const updates = new Set(record.updates); record.updates.clear();
289
+ if (dirty) {
290
+ let after = record.thread.turns.filter(turn => turn.roomMessage).at(-1)?.id;
291
+ do {
292
+ const page = await this.rooms.messages(room.id, { after }); check();
293
+ this.publish(threadId, record, mergeTurns(record.thread.turns, dotsRoomTurns(room, page.items)));
294
+ for (const message of page.items) updates.delete(message.id);
295
+ if (after && page.after === after) throw new Error('点点增量游标未推进。');
296
+ after = page.after;
297
+ } while (after);
298
+ }
299
+ for (const id of updates) {
300
+ const page = await this.rooms.messages(room.id, { around: id, limit: 1 }); check();
301
+ const message = page.items.find(message => message.id === id);
302
+ if (!message) throw new Error('点点消息更新尚未确认。');
303
+ const turns = dotsRoomTurns(room, [message]);
304
+ if (record.thread.turns.some(turn => turn.id === id)) this.publish(threadId, record, mergeTurns(record.thread.turns, turns));
305
+ else this.publishChanges(threadId, record, turns.map(turn => ({ ...turn, historyOnly: true })));
306
+ }
307
+ } while (record.dirty || record.updates.size);
308
+ this.trimWindow(record);
309
+ })();
310
+ record.refresh = work;
311
+ try { await work; } finally { if (record.refresh === work) record.refresh = null; }
312
+ }
313
+ async history(threadId, cursor, options = {}) {
314
+ await this.connect();
315
+ const room = this.bindings.get(threadId) || await this.room(threadId), epoch = this.epoch;
316
+ let position; try { position = JSON.parse(cursor); } catch {}
317
+ if (position?.kind !== 'dots-room-messages' || position.threadId !== threadId || position.roomId !== room.id || !position.before) {
318
+ throw Object.assign(new Error('点点历史游标已失效,请重新读取。'), { code: 'history_cursor_expired' });
319
+ }
320
+ const before = position.before, limit = options.itemLimit ?? pageSize;
321
+ const key = JSON.stringify([epoch, room.id, before, limit]);
322
+ let load = this.historyLoads.get(key);
323
+ if (!load) {
324
+ const record = this.threads.get(threadId);
325
+ load = { record, sequence: record?.sequence ?? 0, work: this.rooms.messages(room.id, { before, limit }) };
326
+ this.historyLoads.set(key, load);
327
+ }
328
+ let page; try { page = await load.work; } finally { if (this.historyLoads.get(key) === load) this.historyLoads.delete(key); }
329
+ if (epoch !== this.epoch) throw new Error('点点连接已变化,请重新同步。');
330
+ if (page.before === before) throw new Error('点点历史分页未推进。');
331
+ const record = this.threads.get(threadId);
332
+ // All consumers share the page's read boundary. If changes were discarded,
333
+ // keep the current cursor and let the existing local history retry reread it.
334
+ if (record !== load.record || record?.needsReload || record?.recoverAfter > load.sequence) {
335
+ throw new Error('点点历史读取期间消息已变化,请重试加载。');
336
+ }
337
+ const turns = dotsRoomTurns(room, page.items).map(turn => {
338
+ const changed = record?.changes.get(turn.id);
339
+ return changed?.sequence > load.sequence ? { ...changed.turn, historyOnly: false } : turn;
340
+ });
341
+ return { thread: this.thread(threadId, turns), nextCursor: this.historyCursor(threadId, null, page.before) };
342
+ }
343
+ async send(args) {
344
+ try { return await this.sendMessage(args); }
345
+ catch (error) { if (error.deliveryUnknown === undefined) error.deliveryUnknown = false; throw error; }
346
+ }
347
+ async sendMessage(args) {
348
+ await this.connect(); this.requireWritable(args.threadId);
349
+ const record = this.threads.get(args.threadId), room = this.bindings.get(args.threadId), epoch = this.epoch;
350
+ if (!record || record.needsReload || record.hydrating || !room) throw new Error('请先完成点点消息同步。');
351
+ if (typeof args.prompt !== 'string' || args.prompt.length > 32000) throw new Error('点点消息文字无效或超过 32000 字符。');
352
+ const files = await readMessageUploads(args, this.uploadsRoot);
353
+ const compactId = (args.clientUserMessageId || randomUUID()).replace(/-/g, '');
354
+ if (!/^[a-f0-9]{32}$/i.test(compactId)) throw new Error('消息确认标识无效。');
355
+ const requestId = compactId.replace(/^(........)(....)(....)(....)(............)$/, '$1-$2-$3-$4-$5');
356
+ if (!args.prompt.trim() && !files.length) throw new Error('请输入文字或添加附件。');
357
+ const attachments = [];
358
+ for (const file of files) {
359
+ if (epoch !== this.epoch) throw new Error('点点连接已变化,请核对后发送。');
360
+ const boundary = `relax-${randomUUID()}`;
361
+ const name = file.name.replaceAll('"', '%22').replaceAll('\\', '%5C');
362
+ const body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`), file.bytes, Buffer.from(`\r\n--${boundary}--\r\n`)]);
363
+ const result = await this.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/files`, {
364
+ method: 'POST', body, contentType: `multipart/form-data; boundary=${boundary}` })
365
+ .catch(error => { error.deliveryUnknown = false; throw error; });
366
+ if (!result?.id) throw new Error('点点附件上传未确认。');
367
+ attachments.push({ type: 'file', file_id: result.id });
368
+ }
369
+ if (epoch !== this.epoch) throw new Error('点点连接已变化,请核对后发送。');
370
+ const result = await this.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/messages`, {
371
+ method: 'POST', body: { content: { text: args.prompt, ...(attachments.length ? { attachments } : {}) },
372
+ request_id: requestId, idempotency_token: requestId } });
373
+ if (epoch !== this.epoch || !result?.id) throw Object.assign(new Error('发送结果未确认,请核对消息。'), { deliveryUnknown: true });
374
+ record.dirty++;
375
+ void this.refreshMessages(args.threadId, record).catch(error => {
376
+ if (epoch !== this.epoch) return;
377
+ record.needsReload = true; this.emit('subscription-error', error); this.emit('history-invalidated', args.threadId);
378
+ });
379
+ return { result: { messageId: result.id, requestId }, usage: { kind: 'dots-message', turnId: null } };
380
+ }
381
+ async markRead(threadId, body) {
382
+ const record = this.threads.get(threadId), room = this.bindings.get(threadId);
383
+ if (!this.ready || !record || record.needsReload || !room || body.roomId !== room.id
384
+ || body.revision !== record.thread.chatMessageRevision || body.lastMessageAt !== record.thread.lastMessageAt) return;
385
+ if (!body.lastMessageAt || room.last_read_at && Date.parse(room.last_read_at) >= Date.parse(body.lastMessageAt)) return;
386
+ const epoch = this.epoch;
387
+ await this.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/read`, { method: 'POST', body: { last_read_at: body.lastMessageAt } });
388
+ if (epoch === this.epoch && (!room.last_read_at || Date.parse(room.last_read_at) < Date.parse(body.lastMessageAt))) room.last_read_at = body.lastMessageAt;
389
+ }
390
+ executionNotification(method, params) {
391
+ if (params.item?.type !== 'subAgentActivity' || !['item/started', 'item/completed'].includes(method)) return;
392
+ const record = this.threads.get(params.threadId), item = projectDotsChatItem(params.item);
393
+ if (!item || !record || record.hydrating || record.needsReload) return;
394
+ const turn = { id: `activity:${params.turnId}:${item.id}`, status: 'completed', items: [item] };
395
+ this.publish(params.threadId, record, mergeTurns(record.thread.turns, [turn]));
396
+ }
397
+ respondApproval(threadId, body) { this.requireWritable(threadId); return this.transport.respondApproval(threadId, body); }
398
+ acquireActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
399
+ acquirePanelActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
400
+ isHistorySyncing() { return false; }
401
+ async readContext() { return { available: false }; }
402
+ request() { return Promise.reject(new Error('点点主聊天不提供此执行线程操作。')); }
403
+ async unsubscribeThread() {}
404
+ keepThreadLoaded(_id, record) { return Boolean(record && this.ready); }
405
+ isThreadObserved(id, record) { return this.keepThreadLoaded(id, record); }
406
+ close() {
407
+ this.closed = true; clearTimeout(this.reconnectTimer); this.rooms.close(); this.invalidate(); this.chatActivity.dispose();
408
+ this.transport.off('disconnected', this.hostDisconnected); this.transport.off('connected', this.hostConnected);
409
+ this.transport.off('host-notification', this.hostNotification); this.transport.off('requests-changed', this.hostRequestsChanged);
410
+ for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
411
+ }
412
+ }