@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59

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 (101) hide show
  1. package/README.md +105 -105
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +403 -403
  4. package/dist/bin/publish-android-release.mjs +63 -63
  5. package/dist/bin/release-upload-server.mjs +190 -190
  6. package/dist/bin/set-relay-admin-password.mjs +11 -0
  7. package/dist/build-platform.json +1 -1
  8. package/dist/get-shared-service-url.ps1 +14 -14
  9. package/dist/shared/code-browser.cjs +159 -159
  10. package/dist/shared/file-changes.cjs +31 -31
  11. package/dist/shared/file-citations.cjs +117 -117
  12. package/dist/shared/file-mentions.cjs +92 -92
  13. package/dist/shared/message-delivery.cjs +115 -115
  14. package/dist/shared/p2p-file-channel.cjs +87 -87
  15. package/dist/shared/release-version.cjs +31 -0
  16. package/dist/shared/server-requests.cjs +311 -311
  17. package/dist/shared/timeline-sync.cjs +173 -173
  18. package/dist/skills/code-relax/SKILL.md +22 -22
  19. package/dist/src/bounded-process-log.mjs +63 -63
  20. package/dist/src/celsius-client.mjs +107 -107
  21. package/dist/src/dots-approval-timeline.mjs +99 -99
  22. package/dist/src/dots-avatar.mjs +42 -42
  23. package/dist/src/dots-chat-projection.mjs +50 -50
  24. package/dist/src/dots-chat-server.mjs +483 -483
  25. package/dist/src/dots-message-images.mjs +319 -319
  26. package/dist/src/dots-profile.mjs +77 -77
  27. package/dist/src/dots-room-activity.mjs +172 -172
  28. package/dist/src/dots-room-client.mjs +137 -135
  29. package/dist/src/dots-room-projection.mjs +94 -94
  30. package/dist/src/dots-schedules.mjs +66 -66
  31. package/dist/src/dots-summary-tasks.mjs +32 -32
  32. package/dist/src/dots-task-catalog.mjs +430 -430
  33. package/dist/src/file-transfer-range.mjs +22 -22
  34. package/dist/src/file-upload-session.mjs +146 -130
  35. package/dist/src/hosted-history-app-server.mjs +103 -103
  36. package/dist/src/hosted-history-cache.mjs +113 -113
  37. package/dist/src/lan-socket-server.mjs +338 -266
  38. package/dist/src/managed-app-server.mjs +373 -373
  39. package/dist/src/managed-endpoint.mjs +25 -25
  40. package/dist/src/message-upload-files.mjs +109 -109
  41. package/dist/src/onboarding.mjs +12 -12
  42. package/dist/src/platform/README.md +1 -1
  43. package/dist/src/platform/codex-protocol.mjs +46 -46
  44. package/dist/src/platform/desktop-project-store.mjs +219 -208
  45. package/dist/src/platform/linux/desktop-host.mjs +87 -87
  46. package/dist/src/platform/linux/process.mjs +53 -53
  47. package/dist/src/platform/linux/system.mjs +50 -50
  48. package/dist/src/platform/linux/user-environment.mjs +9 -9
  49. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  50. package/dist/src/platform/macos/desktop-host.mjs +86 -86
  51. package/dist/src/platform/macos/user-environment.mjs +9 -9
  52. package/dist/src/platform/native-package.mjs +58 -58
  53. package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
  54. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  55. package/dist/src/platform/windows/credential-store.mjs +27 -27
  56. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  57. package/dist/src/platform/windows/desktop-host.mjs +65 -65
  58. package/dist/src/platform/windows/desktop-info.ps1 +38 -38
  59. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  60. package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
  61. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  62. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  63. package/dist/src/platform/windows/project-locations.mjs +16 -16
  64. package/dist/src/platform/windows/service-host.mjs +35 -35
  65. package/dist/src/platform/windows/user-environment.mjs +52 -52
  66. package/dist/src/project-management.mjs +58 -58
  67. package/dist/src/runtime-discovery.mjs +47 -47
  68. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  69. package/dist/src/self-relay/admin-auth.mjs +59 -0
  70. package/dist/src/self-relay/admin-login-page.mjs +7 -0
  71. package/dist/src/self-relay/admin-page.mjs +87 -80
  72. package/dist/src/self-relay/admin-server.mjs +49 -18
  73. package/dist/src/self-relay/admin-style.mjs +58 -14
  74. package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
  75. package/dist/src/self-relay/admin-workstations.mjs +143 -0
  76. package/dist/src/self-relay/connection-reporting.mjs +38 -0
  77. package/dist/src/self-relay/connector.mjs +444 -439
  78. package/dist/src/self-relay/demo.mjs +31 -31
  79. package/dist/src/self-relay/diagnostics.mjs +54 -54
  80. package/dist/src/self-relay/email-accounts.mjs +183 -183
  81. package/dist/src/self-relay/file-wire.mjs +13 -13
  82. package/dist/src/self-relay/lifecycle.mjs +11 -0
  83. package/dist/src/self-relay/p2p-probe.mjs +168 -168
  84. package/dist/src/self-relay/phone-connections.mjs +55 -0
  85. package/dist/src/self-relay/privacy-page.mjs +26 -26
  86. package/dist/src/self-relay/public-releases.mjs +67 -67
  87. package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
  88. package/dist/src/self-relay/server.mjs +63 -3
  89. package/dist/src/self-relay/usage-meter.mjs +141 -141
  90. package/dist/src/server.mjs +23 -9
  91. package/dist/src/service-doctor.mjs +148 -148
  92. package/dist/src/service-install.mjs +97 -97
  93. package/dist/src/service-lifecycle.mjs +139 -139
  94. package/dist/src/service-update.mjs +6 -3
  95. package/dist/src/shared-app-server.mjs +683 -666
  96. package/dist/src/shared-catalog.mjs +132 -132
  97. package/dist/src/shared-thread-stream.mjs +165 -165
  98. package/dist/src/usage-reset.mjs +17 -17
  99. package/dist/src/workstation-downloads.mjs +120 -120
  100. package/package.json +4 -4
  101. package/tools/postinstall.mjs +186 -186
@@ -1,22 +1,22 @@
1
- export const FILE_READ_LIMIT = 20 * 1024 * 1024;
2
- export const FILE_PART_BYTES = 256 * 1024;
3
- export class FileRangeError extends Error {
4
- constructor(statusCode, message) { super(message); this.statusCode = statusCode; }
5
- }
6
-
7
- export function fileRange(query, size, revision) {
8
- const offset = Number(query.get('offset') || 0);
9
- const length = Number(query.get('length') || FILE_PART_BYTES);
10
- if (!Number.isSafeInteger(offset) || offset < 0 || offset >= size ||
11
- !Number.isSafeInteger(length) || length < 1 || length > FILE_PART_BYTES) {
12
- throw new FileRangeError(400, '无效的文件读取范围。');
13
- }
14
- if ((offset && !query.get('revision')) || (query.has('revision') && query.get('revision') !== revision)) {
15
- throw new FileRangeError(409, '文件已变化,请重新打开。');
16
- }
17
- return { offset, length: Math.min(length, size - offset) };
18
- }
19
-
20
- export function fileRevision(stat) {
21
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
22
- }
1
+ export const FILE_READ_LIMIT = 20 * 1024 * 1024;
2
+ export const FILE_PART_BYTES = 256 * 1024;
3
+ export class FileRangeError extends Error {
4
+ constructor(statusCode, message) { super(message); this.statusCode = statusCode; }
5
+ }
6
+
7
+ export function fileRange(query, size, revision) {
8
+ const offset = Number(query.get('offset') || 0);
9
+ const length = Number(query.get('length') || FILE_PART_BYTES);
10
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= size ||
11
+ !Number.isSafeInteger(length) || length < 1 || length > FILE_PART_BYTES) {
12
+ throw new FileRangeError(400, '无效的文件读取范围。');
13
+ }
14
+ if ((offset && !query.get('revision')) || (query.has('revision') && query.get('revision') !== revision)) {
15
+ throw new FileRangeError(409, '文件已变化,请重新打开。');
16
+ }
17
+ return { offset, length: Math.min(length, size - offset) };
18
+ }
19
+
20
+ export function fileRevision(stat) {
21
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
22
+ }
@@ -1,130 +1,146 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
-
5
- export const MAX_FILE_BYTES = 20 * 1024 * 1024;
6
- export const MAX_FILE_COUNT = 4;
7
- export const MAX_TOTAL_BYTES = 40 * 1024 * 1024;
8
- export const FILE_CHUNK_BYTES = 256 * 1024;
9
-
10
- function validName(value) {
11
- return typeof value === 'string' && value.length > 0 && value.length <= 180 &&
12
- value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value) && !/[. ]$/.test(value) &&
13
- !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(value);
14
- }
15
-
16
- async function uniqueDestination(directory, name, partial) {
17
- const dot = name.lastIndexOf('.');
18
- const stem = dot > 0 ? name.slice(0, dot) : name;
19
- const extension = dot > 0 ? name.slice(dot) : '';
20
- for (let index = 0; ; index++) {
21
- const suffix = index ? ` (${index})` : '';
22
- const candidate = index ? `${stem.slice(0, 180 - extension.length - suffix.length)}${suffix}${extension}` : name;
23
- if (!candidate || candidate.length > 180) throw new Error('同名文件过多,文件名无法继续编号');
24
- const destination = path.join(directory, candidate);
25
- try {
26
- await fs.link(partial, destination);
27
- await fs.unlink(partial);
28
- return { name: candidate, path: destination };
29
- } catch (error) {
30
- if (error.code !== 'EEXIST') throw error;
31
- }
32
- }
33
- }
34
-
35
- export function createFileUploadSession(root) {
36
- let upload;
37
- const completed = new Map();
38
- let completedBytes = 0;
39
- const directory = path.join(root, 'incoming');
40
- const clearActive = async () => {
41
- const current = upload;
42
- upload = undefined;
43
- if (!current) return;
44
- await current.handle.close().catch(() => {});
45
- await fs.unlink(current.partial).catch(() => {});
46
- };
47
- const clear = async () => {
48
- await clearActive();
49
- for (const file of completed.values()) {
50
- await fs.unlink(file.path).catch(() => {});
51
- if (file.preview) await fs.unlink(file.preview.path).catch(() => {});
52
- }
53
- completed.clear(); completedBytes = 0;
54
- };
55
- return {
56
- async receive(message) {
57
- if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
58
- if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
59
- if (message.type === 'commit') {
60
- if (upload) throw new Error('文件尚未上传完成');
61
- completed.clear(); completedBytes = 0;
62
- return { type: 'committed' };
63
- }
64
- if (message.type === 'remove') {
65
- if (upload) throw new Error('请等待当前文件上传完成');
66
- const file = completed.get(message.id);
67
- if (!file) throw new Error('文件上传会话已失效');
68
- await fs.unlink(file.path);
69
- if (file.preview) await fs.unlink(file.preview.path);
70
- completed.delete(message.id); completedBytes -= file.size;
71
- return { type: 'removed', id: message.id };
72
- }
73
- if (message.type === 'start') {
74
- if (upload) throw new Error('已有文件正在上传');
75
- if (!validName(message.name)) throw new Error('文件名无效');
76
- if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
77
- throw new Error('单个文件不得超过 20 MiB');
78
- }
79
- const parent = message.previewOf ? completed.get(message.previewOf) : null;
80
- if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
81
- if (!parent && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
82
- throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
83
- }
84
- if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
85
- const id = crypto.randomUUID();
86
- await fs.mkdir(directory, { recursive: true });
87
- const partial = path.join(directory, `.upload-${id}`);
88
- const handle = await fs.open(partial, 'wx');
89
- upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
90
- expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
91
- return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
92
- }
93
- const current = upload;
94
- if (!current || message.id !== current.id) throw new Error('文件上传会话已失效');
95
- if (message.type === 'chunk') {
96
- if (!Number.isSafeInteger(message.offset) || message.offset !== current.offset) {
97
- return { type: 'offset', id: current.id, offset: current.offset };
98
- }
99
- if (!Buffer.isBuffer(message.data)) {
100
- throw new Error('文件分片格式无效');
101
- }
102
- const bytes = message.data;
103
- if (!bytes.length || bytes.length > FILE_CHUNK_BYTES || current.offset + bytes.length > current.size) {
104
- throw new Error('文件分片大小无效');
105
- }
106
- await current.handle.writeFile(bytes);
107
- current.hash.update(bytes);
108
- current.offset += bytes.length;
109
- return { type: 'offset', id: current.id, offset: current.offset };
110
- }
111
- if (message.type === 'finish') {
112
- if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
113
- await clear(); throw new Error('文件大小或 SHA-256 校验失败');
114
- }
115
- await current.handle.sync();
116
- await current.handle.close();
117
- upload = undefined;
118
- let saved;
119
- try { saved = await uniqueDestination(directory, current.name, current.partial); }
120
- catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
121
- const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
122
- if (current.parent) current.parent.preview = result;
123
- else { completed.set(current.id, result); completedBytes += current.size; }
124
- return result;
125
- }
126
- throw new Error('不支持的文件上传操作');
127
- },
128
- close: clear,
129
- };
130
- }
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const MAX_FILE_BYTES = 20 * 1024 * 1024;
6
+ export const MAX_FILE_COUNT = 4;
7
+ export const MAX_TOTAL_BYTES = 40 * 1024 * 1024;
8
+ export const FILE_CHUNK_BYTES = 256 * 1024;
9
+
10
+ function validName(value) {
11
+ return typeof value === 'string' && value.length > 0 && value.length <= 180 &&
12
+ value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value) && !/[. ]$/.test(value) &&
13
+ !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(value);
14
+ }
15
+
16
+ async function uniqueDestination(directory, name, partial) {
17
+ const dot = name.lastIndexOf('.');
18
+ const stem = dot > 0 ? name.slice(0, dot) : name;
19
+ const extension = dot > 0 ? name.slice(dot) : '';
20
+ for (let index = 0; ; index++) {
21
+ const suffix = index ? ` (${index})` : '';
22
+ const candidate = index ? `${stem.slice(0, 180 - extension.length - suffix.length)}${suffix}${extension}` : name;
23
+ if (!candidate || candidate.length > 180) throw new Error('同名文件过多,文件名无法继续编号');
24
+ const destination = path.join(directory, candidate);
25
+ try {
26
+ await fs.link(partial, destination);
27
+ await fs.unlink(partial);
28
+ return { name: candidate, path: destination };
29
+ } catch (error) {
30
+ if (error.code !== 'EEXIST') throw error;
31
+ }
32
+ }
33
+ }
34
+
35
+ export function createFileUploadSession(root) {
36
+ let upload;
37
+ let committed = false;
38
+ const files = new Map();
39
+ const completed = new Map();
40
+ let completedBytes = 0;
41
+ const directory = path.join(root, 'incoming');
42
+ const clearActive = async () => {
43
+ const current = upload;
44
+ upload = undefined;
45
+ if (!current) return;
46
+ await current.handle.close().catch(() => {});
47
+ await fs.unlink(current.partial).catch(() => {});
48
+ };
49
+ const clear = async () => {
50
+ await clearActive();
51
+ if (committed) return;
52
+ for (const file of completed.values()) {
53
+ await fs.unlink(file.path).catch(() => {});
54
+ if (file.preview) await fs.unlink(file.preview.path).catch(() => {});
55
+ }
56
+ completed.clear(); files.clear(); completedBytes = 0;
57
+ };
58
+ return {
59
+ async receive(message) {
60
+ if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
61
+ if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
62
+ if (message.type === 'commit') {
63
+ if (upload) throw new Error('文件尚未上传完成');
64
+ committed = true;
65
+ return { type: 'committed' };
66
+ }
67
+ if (message.type === 'remove') {
68
+ if (committed) throw new Error('文件批次已提交');
69
+ if (upload) throw new Error('请等待当前文件上传完成');
70
+ const file = completed.get(message.id);
71
+ if (!file) throw new Error('文件上传会话已失效');
72
+ await fs.unlink(file.path);
73
+ if (file.preview) await fs.unlink(file.preview.path);
74
+ completed.delete(message.id); completedBytes -= file.size;
75
+ for (const [key, value] of files) if (value.id === message.id || value.previewOf === message.id) files.delete(key);
76
+ return { type: 'removed', id: message.id };
77
+ }
78
+ if (message.type === 'start') {
79
+ if (typeof message.fileKey !== 'string' || !/^[0-9]+(?:-preview)?$/.test(message.fileKey)) throw new Error('文件批次编号无效');
80
+ const previous = files.get(message.fileKey);
81
+ if (previous) {
82
+ if (previous.originalName !== message.name || previous.size !== message.size ||
83
+ previous.expected !== message.sha256?.toLowerCase() || previous.previewOf !== message.previewOf) throw new Error('续传文件身份不一致');
84
+ return previous.result || { type: 'started', id: previous.id, offset: previous.offset, chunkBytes: FILE_CHUNK_BYTES };
85
+ }
86
+ if (committed) throw new Error('文件批次已提交');
87
+ if (upload) throw new Error('已有文件正在上传');
88
+ if (!validName(message.name)) throw new Error('文件名无效');
89
+ if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
90
+ throw new Error('单个文件不得超过 20 MiB');
91
+ }
92
+ const parent = message.previewOf ? completed.get(message.previewOf) : null;
93
+ if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
94
+ if (!parent && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
95
+ throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
96
+ }
97
+ if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
98
+ const id = crypto.randomUUID();
99
+ await fs.mkdir(directory, { recursive: true });
100
+ const partial = path.join(directory, `.upload-${id}`);
101
+ const handle = await fs.open(partial, 'wx');
102
+ upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
103
+ originalName: message.name, previewOf: message.previewOf,
104
+ expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
105
+ files.set(message.fileKey, upload);
106
+ return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
107
+ }
108
+ const current = upload;
109
+ if (!current || message.id !== current.id) throw new Error('文件上传会话已失效');
110
+ if (message.type === 'chunk') {
111
+ if (!Number.isSafeInteger(message.offset) || message.offset !== current.offset) {
112
+ return { type: 'offset', id: current.id, offset: current.offset };
113
+ }
114
+ if (!Buffer.isBuffer(message.data)) {
115
+ throw new Error('文件分片格式无效');
116
+ }
117
+ const bytes = message.data;
118
+ if (!bytes.length || bytes.length > FILE_CHUNK_BYTES || current.offset + bytes.length > current.size) {
119
+ throw new Error('文件分片大小无效');
120
+ }
121
+ await current.handle.writeFile(bytes);
122
+ current.hash.update(bytes);
123
+ current.offset += bytes.length;
124
+ return { type: 'offset', id: current.id, offset: current.offset };
125
+ }
126
+ if (message.type === 'finish') {
127
+ if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
128
+ await clear(); throw new Error('文件大小或 SHA-256 校验失败');
129
+ }
130
+ await current.handle.sync();
131
+ await current.handle.close();
132
+ upload = undefined;
133
+ let saved;
134
+ try { saved = await uniqueDestination(directory, current.name, current.partial); }
135
+ catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
136
+ const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
137
+ current.result = result;
138
+ if (current.parent) current.parent.preview = result;
139
+ else { completed.set(current.id, result); completedBytes += current.size; }
140
+ return result;
141
+ }
142
+ throw new Error('不支持的文件上传操作');
143
+ },
144
+ close: clear,
145
+ };
146
+ }
@@ -1,103 +1,103 @@
1
- import { SharedAppServer } from './shared-app-server.mjs';
2
- import { historyCacheScope, completedHistory, turnMetadata } from './hosted-history-cache.mjs';
3
-
4
- // Hosted history uses a disk range cache; local kernel history retains its own storage.
5
- export class HostedHistoryAppServer extends SharedAppServer {
6
- constructor(url, historyCache) {
7
- super(url);
8
- this.historyCache = historyCache;
9
- this.historyHeadKey = 'latest';
10
- this.diskHeads = new Map(); this.invalidatedTurns = new Set(); this.windowSaves = new Map();
11
- this.historyVersions = new Map();
12
- this.on('disconnected', () => { this.diskHeads.clear(); this.invalidatedTurns.clear(); this.historyVersions.clear(); });
13
- }
14
-
15
- historyScope(threadId) { return historyCacheScope(this.historyIdentity, this.historySource, threadId); }
16
- historyVersion(threadId) { return `${this.epoch}:${this.historyVersions.get(threadId) || 0}`; }
17
-
18
- async restoreHistoryHead(threadId) {
19
- const scope = this.historyScope(threadId);
20
- const result = await this.historyCache?.get(scope, this.historyHeadKey);
21
- if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
22
- return result;
23
- }
24
-
25
- saveHistoryWindow(threadId, immediate = false) {
26
- const record = this.threads.get(threadId), scope = this.historyScope(threadId);
27
- if (!this.historyCache || !scope || !record || record.hydrating || record.needsReload
28
- || record.historyTrimmed || record.metadataOnly || record.readOnly || !completedHistory(record.thread.turns)) return;
29
- const value = structuredClone({ updatedAt: record.thread.updatedAt, turns: record.thread.turns,
30
- nextCursor: record.nextCursor, revision: record.thread.chatRevision,
31
- ...(record.approvalBeforeMs !== undefined ? { approvalBeforeMs: record.approvalBeforeMs } : {}) });
32
- const pending = this.windowSaves.get(scope);
33
- if (pending) { clearTimeout(pending); this.windowSaves.delete(scope); }
34
- const save = () => { this.windowSaves.delete(scope); return this.historyCache.put(scope, this.historyHeadKey, value); };
35
- if (immediate) return save();
36
- const timer = setTimeout(save, 200); timer.unref(); this.windowSaves.set(scope, timer);
37
- }
38
-
39
- invalidateHistory(method, params) {
40
- const id = params.threadId || params.thread?.id, scope = this.historyScope(id);
41
- if (!scope || !this.historyCache) return;
42
- const turnId = params.turnId || params.turn?.id;
43
- const whole = ['thread/deleted', 'thread/compacted'].includes(method);
44
- if (!whole && (!turnId || !/^(?:item\/|turn\/)/.test(method))) return;
45
- this.historyVersions.set(id, (this.historyVersions.get(id) || 0) + 1);
46
- clearTimeout(this.windowSaves.get(scope)); this.windowSaves.delete(scope);
47
- const key = `${scope}:${turnId}`;
48
- if (whole || !this.invalidatedTurns.has(key)) {
49
- this.invalidatedTurns.add(key); this.diskHeads.delete(id);
50
- void this.historyCache.invalidate(scope, whole ? undefined : turnId);
51
- }
52
- if (whole) {
53
- const record = this.threads.get(id);
54
- if (record) record.needsReload = true;
55
- this.emit('history-invalidated', id);
56
- }
57
- if (method === 'turn/completed') this.invalidatedTurns.delete(key);
58
- }
59
-
60
- async compactTurn(threadId, turn, cursor = null, apply, limit = 100) {
61
- const scope = this.historyScope(threadId), candidate = this.diskHeads.get(threadId);
62
- const version = this.historyVersion(threadId);
63
- if (cursor && !turn.status && this.historyCache) {
64
- const final = await this.historyCache.get(scope, `turn:${turn.id}`);
65
- if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
66
- if (final) turn = { ...final.turns[0], ...turn };
67
- }
68
- const saved = !cursor && candidate?.head?.turns.find(value => value.id === turn.id);
69
- if (scope && this.historyCache && saved && candidate.scope === scope && candidate.updatedAt != null
70
- && candidate.head.updatedAt === candidate.updatedAt && completedHistory([saved])
71
- && turnMetadata(saved) === turnMetadata(turn)) {
72
- const result = { ...saved, ...turn, items: saved.items, itemPage: saved.itemPage };
73
- apply?.(result); return result;
74
- }
75
- const result = await super.compactTurn(threadId, turn, cursor, apply, limit);
76
- if (this.historyCache && scope === this.historyScope(threadId) && version === this.historyVersion(threadId)
77
- && completedHistory([{ ...turn, items: [] }])) {
78
- const { items, itemPage, ...metadata } = turn;
79
- await this.historyCache.put(scope, `turn:${turn.id}`, { turns: [metadata] });
80
- }
81
- return result;
82
- }
83
-
84
- async history(threadId, cursor, options = {}) {
85
- await this.connect();
86
- let position;
87
- try { position = JSON.parse(cursor); } catch { position = null; }
88
- if (position?.threadId !== threadId) return super.history(threadId, cursor, options);
89
- const scope = this.historyScope(threadId);
90
- const version = this.historyVersion(threadId);
91
- const key = JSON.stringify(['history', position.turnId, position.itemCursor, position.turnCursor, options.itemLimit ?? 100]);
92
- const cached = await this.historyCache?.get(scope, key);
93
- if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
94
- if (cached && version === this.historyVersion(threadId)) return { thread: { id: threadId, turns: cached.turns },
95
- nextCursor: cached.position ? JSON.stringify(cached.position) : null };
96
- const result = await super.history(threadId, cursor, options);
97
- if (scope === this.historyScope(threadId) && version === this.historyVersion(threadId) && completedHistory(result.thread.turns)) {
98
- const next = result.nextCursor ? JSON.parse(result.nextCursor) : null;
99
- await this.historyCache?.put(scope, key, { turns: result.thread.turns, position: next });
100
- }
101
- return result;
102
- }
103
- }
1
+ import { SharedAppServer } from './shared-app-server.mjs';
2
+ import { historyCacheScope, completedHistory, turnMetadata } from './hosted-history-cache.mjs';
3
+
4
+ // Hosted history uses a disk range cache; local kernel history retains its own storage.
5
+ export class HostedHistoryAppServer extends SharedAppServer {
6
+ constructor(url, historyCache) {
7
+ super(url);
8
+ this.historyCache = historyCache;
9
+ this.historyHeadKey = 'latest';
10
+ this.diskHeads = new Map(); this.invalidatedTurns = new Set(); this.windowSaves = new Map();
11
+ this.historyVersions = new Map();
12
+ this.on('disconnected', () => { this.diskHeads.clear(); this.invalidatedTurns.clear(); this.historyVersions.clear(); });
13
+ }
14
+
15
+ historyScope(threadId) { return historyCacheScope(this.historyIdentity, this.historySource, threadId); }
16
+ historyVersion(threadId) { return `${this.epoch}:${this.historyVersions.get(threadId) || 0}`; }
17
+
18
+ async restoreHistoryHead(threadId) {
19
+ const scope = this.historyScope(threadId);
20
+ const result = await this.historyCache?.get(scope, this.historyHeadKey);
21
+ if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
22
+ return result;
23
+ }
24
+
25
+ saveHistoryWindow(threadId, immediate = false) {
26
+ const record = this.threads.get(threadId), scope = this.historyScope(threadId);
27
+ if (!this.historyCache || !scope || !record || record.hydrating || record.needsReload
28
+ || record.historyTrimmed || record.metadataOnly || record.readOnly || !completedHistory(record.thread.turns)) return;
29
+ const value = structuredClone({ updatedAt: record.thread.updatedAt, turns: record.thread.turns,
30
+ nextCursor: record.nextCursor, revision: record.thread.chatRevision,
31
+ ...(record.approvalBeforeMs !== undefined ? { approvalBeforeMs: record.approvalBeforeMs } : {}) });
32
+ const pending = this.windowSaves.get(scope);
33
+ if (pending) { clearTimeout(pending); this.windowSaves.delete(scope); }
34
+ const save = () => { this.windowSaves.delete(scope); return this.historyCache.put(scope, this.historyHeadKey, value); };
35
+ if (immediate) return save();
36
+ const timer = setTimeout(save, 200); timer.unref(); this.windowSaves.set(scope, timer);
37
+ }
38
+
39
+ invalidateHistory(method, params) {
40
+ const id = params.threadId || params.thread?.id, scope = this.historyScope(id);
41
+ if (!scope || !this.historyCache) return;
42
+ const turnId = params.turnId || params.turn?.id;
43
+ const whole = ['thread/deleted', 'thread/compacted'].includes(method);
44
+ if (!whole && (!turnId || !/^(?:item\/|turn\/)/.test(method))) return;
45
+ this.historyVersions.set(id, (this.historyVersions.get(id) || 0) + 1);
46
+ clearTimeout(this.windowSaves.get(scope)); this.windowSaves.delete(scope);
47
+ const key = `${scope}:${turnId}`;
48
+ if (whole || !this.invalidatedTurns.has(key)) {
49
+ this.invalidatedTurns.add(key); this.diskHeads.delete(id);
50
+ void this.historyCache.invalidate(scope, whole ? undefined : turnId);
51
+ }
52
+ if (whole) {
53
+ const record = this.threads.get(id);
54
+ if (record) record.needsReload = true;
55
+ this.emit('history-invalidated', id);
56
+ }
57
+ if (method === 'turn/completed') this.invalidatedTurns.delete(key);
58
+ }
59
+
60
+ async compactTurn(threadId, turn, cursor = null, apply, limit = 100) {
61
+ const scope = this.historyScope(threadId), candidate = this.diskHeads.get(threadId);
62
+ const version = this.historyVersion(threadId);
63
+ if (cursor && !turn.status && this.historyCache) {
64
+ const final = await this.historyCache.get(scope, `turn:${turn.id}`);
65
+ if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
66
+ if (final) turn = { ...final.turns[0], ...turn };
67
+ }
68
+ const saved = !cursor && candidate?.head?.turns.find(value => value.id === turn.id);
69
+ if (scope && this.historyCache && saved && candidate.scope === scope && candidate.updatedAt != null
70
+ && candidate.head.updatedAt === candidate.updatedAt && completedHistory([saved])
71
+ && turnMetadata(saved) === turnMetadata(turn)) {
72
+ const result = { ...saved, ...turn, items: saved.items, itemPage: saved.itemPage };
73
+ apply?.(result); return result;
74
+ }
75
+ const result = await super.compactTurn(threadId, turn, cursor, apply, limit);
76
+ if (this.historyCache && scope === this.historyScope(threadId) && version === this.historyVersion(threadId)
77
+ && completedHistory([{ ...turn, items: [] }])) {
78
+ const { items, itemPage, ...metadata } = turn;
79
+ await this.historyCache.put(scope, `turn:${turn.id}`, { turns: [metadata] });
80
+ }
81
+ return result;
82
+ }
83
+
84
+ async history(threadId, cursor, options = {}) {
85
+ await this.connect();
86
+ let position;
87
+ try { position = JSON.parse(cursor); } catch { position = null; }
88
+ if (position?.threadId !== threadId) return super.history(threadId, cursor, options);
89
+ const scope = this.historyScope(threadId);
90
+ const version = this.historyVersion(threadId);
91
+ const key = JSON.stringify(['history', position.turnId, position.itemCursor, position.turnCursor, options.itemLimit ?? 100]);
92
+ const cached = await this.historyCache?.get(scope, key);
93
+ if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
94
+ if (cached && version === this.historyVersion(threadId)) return { thread: { id: threadId, turns: cached.turns },
95
+ nextCursor: cached.position ? JSON.stringify(cached.position) : null };
96
+ const result = await super.history(threadId, cursor, options);
97
+ if (scope === this.historyScope(threadId) && version === this.historyVersion(threadId) && completedHistory(result.thread.turns)) {
98
+ const next = result.nextCursor ? JSON.parse(result.nextCursor) : null;
99
+ await this.historyCache?.put(scope, key, { turns: result.thread.turns, position: next });
100
+ }
101
+ return result;
102
+ }
103
+ }