@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.31

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 (185) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +62 -58
  7. package/dist/bin/self-relay-admin.mjs +8 -8
  8. package/dist/bin/self-relay-demo.mjs +33 -33
  9. package/dist/bin/self-relay-server.mjs +66 -66
  10. package/dist/build-platform.json +1 -1
  11. package/dist/get-lan-networks.ps1 +40 -40
  12. package/dist/get-shared-service-url.ps1 +6 -6
  13. package/dist/shared/app-server-events.cjs +422 -412
  14. package/dist/shared/code-browser.cjs +159 -159
  15. package/dist/shared/file-changes.cjs +31 -31
  16. package/dist/shared/file-citations.cjs +117 -117
  17. package/dist/shared/file-mentions.cjs +92 -92
  18. package/dist/shared/message-delivery.cjs +54 -54
  19. package/dist/shared/p2p-data-channel.cjs +72 -72
  20. package/dist/shared/p2p-diagnostics.cjs +28 -28
  21. package/dist/shared/p2p-file-channel.cjs +74 -74
  22. package/dist/shared/plan-pricing.cjs +32 -32
  23. package/dist/shared/question-attention.cjs +40 -0
  24. package/dist/shared/server-requests.cjs +219 -219
  25. package/dist/shared/timeline-sync.cjs +173 -173
  26. package/dist/skills/code-relax/SKILL.md +22 -22
  27. package/dist/src/archived-threads.mjs +22 -0
  28. package/dist/src/background-process.mjs +18 -18
  29. package/dist/src/bounded-process-log.mjs +63 -63
  30. package/dist/src/celsius-client.mjs +107 -107
  31. package/dist/src/code-browser.mjs +100 -100
  32. package/dist/src/desktop-launcher.mjs +46 -46
  33. package/dist/src/dots-approval-timeline.mjs +99 -99
  34. package/dist/src/dots-avatar.mjs +42 -42
  35. package/dist/src/dots-chat-projection.mjs +50 -50
  36. package/dist/src/dots-chat-server.mjs +461 -461
  37. package/dist/src/dots-message-images.mjs +319 -319
  38. package/dist/src/dots-profile.mjs +77 -77
  39. package/dist/src/dots-room-activity.mjs +94 -94
  40. package/dist/src/dots-room-client.mjs +135 -135
  41. package/dist/src/dots-room-projection.mjs +94 -94
  42. package/dist/src/dots-schedules.mjs +66 -66
  43. package/dist/src/dots-summary-tasks.mjs +32 -32
  44. package/dist/src/dots-task-catalog.mjs +430 -430
  45. package/dist/src/dots-task-files.mjs +52 -52
  46. package/dist/src/execution-health.mjs +26 -26
  47. package/dist/src/file-transfer-range.mjs +22 -22
  48. package/dist/src/file-upload-session.mjs +130 -130
  49. package/dist/src/hosted-history-app-server.mjs +103 -103
  50. package/dist/src/hosted-history-cache.mjs +113 -113
  51. package/dist/src/lan-socket-server.mjs +265 -263
  52. package/dist/src/managed-app-server.mjs +244 -244
  53. package/dist/src/message-images.mjs +9 -9
  54. package/dist/src/message-upload-files.mjs +74 -74
  55. package/dist/src/onboarding.mjs +12 -12
  56. package/dist/src/plan-pricing.mjs +47 -47
  57. package/dist/src/platform/README.md +12 -12
  58. package/dist/src/platform/codex-protocol.mjs +46 -46
  59. package/dist/src/platform/codex-runtime.mjs +3 -3
  60. package/dist/src/platform/credential-store.mjs +3 -3
  61. package/dist/src/platform/desktop-host.mjs +3 -3
  62. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  63. package/dist/src/platform/desktop-project-store.mjs +208 -149
  64. package/dist/src/platform/file-opener.mjs +9 -0
  65. package/dist/src/platform/linux/README.md +3 -3
  66. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  67. package/dist/src/platform/linux/credential-store.mjs +28 -0
  68. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  69. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  70. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  71. package/dist/src/platform/linux/network-info.mjs +11 -0
  72. package/dist/src/platform/linux/process.mjs +42 -0
  73. package/dist/src/platform/linux/project-locations.mjs +17 -0
  74. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  75. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  76. package/dist/src/platform/linux/service-host.mjs +18 -0
  77. package/dist/src/platform/linux/system.mjs +49 -0
  78. package/dist/src/platform/linux/user-environment.mjs +7 -0
  79. package/dist/src/platform/macos/README.md +3 -3
  80. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  81. package/dist/src/platform/macos/credential-store.mjs +10 -10
  82. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  83. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  84. package/dist/src/platform/macos/network-info.mjs +8 -28
  85. package/dist/src/platform/macos/project-locations.mjs +8 -8
  86. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  87. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  88. package/dist/src/platform/macos/service-host.mjs +18 -18
  89. package/dist/src/platform/macos/system.mjs +29 -29
  90. package/dist/src/platform/macos/user-environment.mjs +7 -7
  91. package/dist/src/platform/native-package.mjs +58 -46
  92. package/dist/src/platform/network-info.mjs +3 -3
  93. package/dist/src/platform/path-identity.mjs +5 -5
  94. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  95. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  96. package/dist/src/platform/project-locations.mjs +3 -3
  97. package/dist/src/platform/runtime-paths.mjs +8 -8
  98. package/dist/src/platform/select.mjs +5 -5
  99. package/dist/src/platform/service-host.mjs +3 -3
  100. package/dist/src/platform/user-environment.mjs +3 -3
  101. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  102. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  103. package/dist/src/platform/windows/background-process.mjs +30 -30
  104. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  105. package/dist/src/platform/windows/credential-store.mjs +27 -27
  106. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  107. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  108. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  109. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  110. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  111. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  112. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  113. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  114. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  115. package/dist/src/platform/windows/network-info.mjs +34 -34
  116. package/dist/src/platform/windows/project-locations.mjs +16 -16
  117. package/dist/src/platform/windows/resident-start.js +20 -20
  118. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  119. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  120. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  121. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  122. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  123. package/dist/src/platform/windows/service-host.mjs +31 -31
  124. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  125. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  126. package/dist/src/platform/windows/user-environment.mjs +50 -50
  127. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  128. package/dist/src/project-archive.mjs +30 -0
  129. package/dist/src/project-create.mjs +121 -119
  130. package/dist/src/project-management.mjs +59 -69
  131. package/dist/src/project-remove.mjs +55 -0
  132. package/dist/src/project-update.mjs +56 -0
  133. package/dist/src/runtime-discovery.mjs +47 -47
  134. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  135. package/dist/src/self-relay/account-connections.mjs +52 -52
  136. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  137. package/dist/src/self-relay/admin-page.mjs +80 -80
  138. package/dist/src/self-relay/admin-server.mjs +74 -74
  139. package/dist/src/self-relay/admin-state.mjs +127 -127
  140. package/dist/src/self-relay/admin-style.mjs +14 -14
  141. package/dist/src/self-relay/android-releases.mjs +61 -61
  142. package/dist/src/self-relay/client.mjs +61 -61
  143. package/dist/src/self-relay/connector.mjs +439 -438
  144. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  145. package/dist/src/self-relay/demo.mjs +37 -37
  146. package/dist/src/self-relay/device-info.mjs +27 -0
  147. package/dist/src/self-relay/device-store.mjs +92 -84
  148. package/dist/src/self-relay/diagnostics.mjs +54 -54
  149. package/dist/src/self-relay/email-accounts.mjs +183 -183
  150. package/dist/src/self-relay/file-wire.mjs +13 -13
  151. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  152. package/dist/src/self-relay/lifecycle.mjs +264 -250
  153. package/dist/src/self-relay/long-pairing.mjs +40 -40
  154. package/dist/src/self-relay/mysql-state.mjs +118 -105
  155. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  156. package/dist/src/self-relay/privacy-page.mjs +26 -26
  157. package/dist/src/self-relay/public-releases.mjs +34 -0
  158. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  159. package/dist/src/self-relay/server.mjs +1006 -932
  160. package/dist/src/self-relay/usage-daily.mjs +55 -55
  161. package/dist/src/self-relay/usage-meter.mjs +141 -141
  162. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  163. package/dist/src/self-relay/wire.mjs +36 -36
  164. package/dist/src/server.mjs +1861 -1778
  165. package/dist/src/service-control.mjs +82 -82
  166. package/dist/src/service-doctor.mjs +110 -110
  167. package/dist/src/service-health.mjs +33 -33
  168. package/dist/src/service-install.mjs +91 -91
  169. package/dist/src/service-lifecycle.mjs +138 -138
  170. package/dist/src/service-restart.mjs +81 -81
  171. package/dist/src/shared-app-server.mjs +615 -611
  172. package/dist/src/shared-catalog.mjs +132 -132
  173. package/dist/src/shared-recovery.mjs +146 -146
  174. package/dist/src/shared-thread-stream.mjs +164 -164
  175. package/dist/src/skill-install.mjs +51 -51
  176. package/dist/src/thread-title-generation.mjs +150 -150
  177. package/dist/src/turn-model-history.mjs +47 -47
  178. package/dist/src/usage-reset.mjs +17 -17
  179. package/dist/src/workstation-downloads.mjs +120 -120
  180. package/dist/tools/rtc-route-helper.ps1 +99 -99
  181. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  182. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  183. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  184. package/package.json +7 -6
  185. package/tools/postinstall.mjs +127 -127
@@ -1,159 +1,159 @@
1
- (function (root, factory) {
2
- if (typeof module === 'object' && module.exports) module.exports = factory();
3
- else root.CodeBrowserProtocol = factory();
4
- })(globalThis, function () {
5
- const maxBytes = 1024 * 1024, maxEntries = 4000, maxLines = 20000;
6
- const maxFileBytes = 20 * 1024 * 1024, imageChunkBytes = 3 * 1024 * 1024;
7
- const mediaTypes = { jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', webp:'image/webp', gif:'image/gif', bmp:'image/bmp', heic:'image/heic', heif:'image/heif', avif:'image/avif', tif:'image/tiff', tiff:'image/tiff', svg:'image/svg+xml', ico:'image/x-icon', mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', webm:'video/webm', mkv:'video/x-matroska', avi:'video/x-msvideo', '3gp':'video/3gpp', '3g2':'video/3gpp2', mpg:'video/mpeg', mpeg:'video/mpeg', m2ts:'video/mp2t', flv:'video/x-flv', wmv:'video/x-ms-wmv' };
8
- const mediaMime = file => file.mimeType || (/^(image|video)\//.test(file.type || '') ? file.type : '') || mediaTypes[(file.name || file.path || '').split('.').at(-1).toLowerCase()] || '';
9
- const mediaKind = file => file.mediaKind || (/^image\//.test(mediaMime(file)) ? 'image' : /^video\//.test(mediaMime(file)) ? 'video' : '');
10
- function imageMime(bytes) {
11
- const ascii = (start, count) => String.fromCharCode(...bytes.slice(start, start + count));
12
- if (bytes[0] === 137 && ascii(1, 3) === 'PNG' && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) return 'image/png';
13
- if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
14
- if (['GIF87a', 'GIF89a'].includes(ascii(0, 6))) return 'image/gif';
15
- if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'image/webp';
16
- fail('不支持此图片格式,当前支持 PNG、JPEG、GIF、WebP。', 415);
17
- }
18
- function fail(message, statusCode = 400) { throw Object.assign(new Error(message), { statusCode }); }
19
- function canonical(value) {
20
- return String(value || '').replaceAll('\\', '/').replace(/\/+$/, '');
21
- }
22
- function key(value) {
23
- const p = canonical(value);
24
- return /^[a-z]:\//i.test(p) || p.startsWith('//') ? p.toLowerCase() : p;
25
- }
26
- function inside(base, candidate) {
27
- const a = key(base), b = key(candidate);
28
- return b === a || b.startsWith(a + '/');
29
- }
30
- function relative(base, candidate) {
31
- if (!inside(base, candidate)) return null;
32
- return canonical(candidate).slice(canonical(base).length).replace(/^\//, '');
33
- }
34
- function child(base, value = '') {
35
- if (typeof value !== 'string' || /[\\:\x00-\x1f]/.test(value) || value.startsWith('/') ||
36
- value.split('/').some(p => p === '..' || p === '.' || (p && /[. ]$/.test(p)))) fail('无效的项目相对路径。');
37
- return canonical(base) + (value ? '/' + value : '') || '/';
38
- }
39
- async function context(rpc, query) {
40
- const threadId = query.get('threadId'), projectId = query.get('projectId');
41
- let thread, project;
42
- if (threadId) ({ thread } = await rpc('thread/read', { threadId, includeTurns: false }));
43
- const id = thread?.projectId || (!threadId && projectId);
44
- if (id && id !== '__unassigned__') ({ project } = await rpc('project/read', { projectId: id }));
45
- const roots = [];
46
- const add = (p, source) => {
47
- if (typeof p !== 'string' || !p || roots.some(r => key(r.path) === key(p))) return;
48
- roots.push({ path: p, source });
49
- };
50
- for (const r of project?.roots || []) add(r.path, '项目');
51
- const environments = thread?.environments || [];
52
- const local = environments.find(e => e.environmentId === 'local');
53
- if (environments.length && !local) fail('当前会话使用远程执行环境,暂不支持本机文件浏览。', 409);
54
- const cwd = local?.cwd || thread?.cwd || '';
55
- // Runtime access roots include tool output directories, not project membership.
56
- if (!project && cwd) add(cwd, '工作目录');
57
- if (!roots.length) fail('请先选择一个项目或对话。', 404);
58
- return { roots, cwd, projectName: project?.name || '', threadId };
59
- }
60
- function selectedRoot(context, query) {
61
- const root = context.roots.find(r => key(r.path) === key(query.get('root')));
62
- if (!root) fail('目录不属于当前项目或会话,请刷新后重试。', 403);
63
- return root.path;
64
- }
65
- function directory(result) {
66
- if (!Array.isArray(result.entries)) fail('工作站没有返回目录条目。', 502);
67
- if (result.entries.length > maxEntries) fail('此目录超过 4000 项,暂不支持浏览。', 413);
68
- const entries = result.entries.map(e => {
69
- if (!e.fileName || /[/\\:\x00-\x1f]/.test(e.fileName) || ['.', '..'].includes(e.fileName)) fail('目录条目名称无效。', 502);
70
- return { name: e.fileName, directory: e.isDirectory === true, file: e.isFile === true };
71
- });
72
- entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
73
- return { entries };
74
- }
75
- function decode(bytes) {
76
- if (bytes.length > maxBytes) fail('文件超过只读预览上限(1 MiB)。', 413);
77
- let encoding = 'utf-8';
78
- if (bytes[0] === 255 && bytes[1] === 254) encoding = 'utf-16le';
79
- else if (bytes[0] === 254 && bytes[1] === 255) encoding = 'utf-16be';
80
- let text;
81
- try { text = new TextDecoder(encoding, { fatal: true }).decode(bytes); }
82
- catch { fail('此文件不是可识别的 UTF-8/UTF-16 文本,暂不支持预览。', 415); }
83
- if (/[\x00-\x08\x0e-\x1f]/.test(text)) fail('此文件包含二进制内容,暂不支持文本预览。', 415);
84
- if (text.split('\n').length > maxLines) fail('文件超过 20000 行,暂不支持预览。', 413);
85
- return { text, encoding, size: bytes.length };
86
- }
87
- function fromBase64(value) {
88
- if (typeof value !== 'string') fail('工作站没有返回文件内容。', 502);
89
- if (value.length > Math.ceil(maxBytes / 3) * 4) fail('文件超过只读预览上限(1 MiB)。', 413);
90
- const { encoding, size } = decode(Uint8Array.from(atob(value), c => c.charCodeAt(0)));
91
- return { dataBase64: value, encoding, size };
92
- }
93
- function diffLines(text) {
94
- let old = 0, newer = 0, oldLeft = 0, newLeft = 0;
95
- return text.replace(/\r\n/g, '\n').split('\n').map(text => {
96
- const row = { text, old: '', newer: '', kind: '' };
97
- const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
98
- if (hunk) {
99
- old = Number(hunk[1]); oldLeft = Number(hunk[2] ?? 1);
100
- newer = Number(hunk[3]); newLeft = Number(hunk[4] ?? 1); row.kind = 'hunk';
101
- } else if (text.startsWith('+') && newLeft > 0) {
102
- row.newer = newer++; newLeft--; row.kind = 'add';
103
- } else if (text.startsWith('-') && oldLeft > 0) {
104
- row.old = old++; oldLeft--; row.kind = 'remove';
105
- } else if (text.startsWith(' ') && oldLeft > 0 && newLeft > 0) {
106
- row.old = old++; row.newer = newer++; oldLeft--; newLeft--;
107
- } else if (!text.startsWith('\\')) { oldLeft = 0; newLeft = 0; }
108
- return row;
109
- });
110
- }
111
- async function change(rpc, query) {
112
- const threadId = query.get('threadId'), turnId = query.get('turnId');
113
- const itemId = query.get('itemId'), path = query.get('path');
114
- if (!threadId || !turnId || !itemId || !path) fail('缺少文件修改记录的定位信息。');
115
- let cursor;
116
- do {
117
- // Only an explicit detail click reads raw items, within the selected turn.
118
- const page = await rpc('thread/items/list', { threadId, turnId, limit: 100,
119
- sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
120
- const item = page.data?.find(entry => entry.turnId === turnId && entry.item?.id === itemId)?.item;
121
- if (item) {
122
- const file = item.type === 'fileChange' && item.changes?.find(value => value.path === path);
123
- if (!file) fail('这条修改记录中没有该文件。', 404);
124
- const diff = typeof file.diff === 'string' ? file.diff : file.diff?.text;
125
- if (typeof diff !== 'string') fail('此条目未提供真实 diff。', 404);
126
- if (new TextEncoder().encode(diff).length > maxBytes || diff.split('\n').length > maxLines) fail('此项 diff 超过预览上限。', 413);
127
- return { diff, turnId };
128
- }
129
- if (page.nextCursor && page.nextCursor === cursor) fail('修改记录分页位置未推进。', 502);
130
- cursor = page.nextCursor;
131
- } while (cursor);
132
- fail('未找到这条文件修改记录。', 404);
133
- }
134
- async function changes(rpc, query) {
135
- const threadId = query.get('threadId');
136
- if (!threadId) fail('请先选择对话以查看修改记录。');
137
- const cursor = query.get('cursor');
138
- const page = await rpc('thread/items/list', { threadId, limit: 20, sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
139
- const changes = []; let diffBytes = 0;
140
- for (const entry of page.data || []) {
141
- if (entry.item?.type !== 'fileChange') continue;
142
- for (const change of entry.item.changes || []) {
143
- const diff = typeof change.diff === 'string' ? change.diff : change.diff?.text;
144
- const bytes = typeof diff === 'string' ? new TextEncoder().encode(diff).length : 0;
145
- const tooLarge = typeof diff === 'string' && (bytes + diffBytes > maxBytes || diff.split('\n').length > maxLines);
146
- if (!tooLarge) diffBytes += bytes;
147
- changes.push({ path: change.path, kind: change.kind, turnId: entry.turnId, itemId: entry.item.id,
148
- diff: tooLarge ? null : diff ?? null, error: tooLarge ? '此项 diff 超过预览上限。' : '' });
149
- }
150
- }
151
- // Return an explicit page even when it has no file changes. Never scan a whole thread implicitly.
152
- return { changes, nextCursor: page.nextCursor || null, scope: '对话文件修改记录(按操作展示)' };
153
- }
154
- function isTextFile(name) {
155
- const base = String(name || '').replaceAll('\\', '/').split('/').at(-1);
156
- return !base.includes('.') || (base.startsWith('.') && !base.slice(1).includes('.')) || /\.(txt|log|md|markdown|rst|csv|tsv|json5?|jsonl|ipynb|ya?ml|toml|ini|cfg|conf|config|xml|xaml|svg|html?|css|scss|less|[cm]?js|jsx|ts|tsx|ets|py|rs|go|java|kt|kts|c|cc|cpp|cxx|h|hpp|cs|swift|m|mm|sh|bash|zsh|ps1|bat|cmd|sql|r|rb|php|vue|svelte|gradle|properties|cmake|make|diff|patch)$/i.test(base);
157
- }
158
- return { isTextFile, maxBytes, maxEntries, maxLines, maxFileBytes, mediaMime, mediaKind, imageChunkBytes, imageMime, fail, key, inside, relative, child, context, selectedRoot, directory, decode, fromBase64, diffLines, change, changes };
159
- });
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodeBrowserProtocol = factory();
4
+ })(globalThis, function () {
5
+ const maxBytes = 1024 * 1024, maxEntries = 4000, maxLines = 20000;
6
+ const maxFileBytes = 20 * 1024 * 1024, imageChunkBytes = 3 * 1024 * 1024;
7
+ const mediaTypes = { jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', webp:'image/webp', gif:'image/gif', bmp:'image/bmp', heic:'image/heic', heif:'image/heif', avif:'image/avif', tif:'image/tiff', tiff:'image/tiff', svg:'image/svg+xml', ico:'image/x-icon', mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', webm:'video/webm', mkv:'video/x-matroska', avi:'video/x-msvideo', '3gp':'video/3gpp', '3g2':'video/3gpp2', mpg:'video/mpeg', mpeg:'video/mpeg', m2ts:'video/mp2t', flv:'video/x-flv', wmv:'video/x-ms-wmv' };
8
+ const mediaMime = file => file.mimeType || (/^(image|video)\//.test(file.type || '') ? file.type : '') || mediaTypes[(file.name || file.path || '').split('.').at(-1).toLowerCase()] || '';
9
+ const mediaKind = file => file.mediaKind || (/^image\//.test(mediaMime(file)) ? 'image' : /^video\//.test(mediaMime(file)) ? 'video' : '');
10
+ function imageMime(bytes) {
11
+ const ascii = (start, count) => String.fromCharCode(...bytes.slice(start, start + count));
12
+ if (bytes[0] === 137 && ascii(1, 3) === 'PNG' && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) return 'image/png';
13
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
14
+ if (['GIF87a', 'GIF89a'].includes(ascii(0, 6))) return 'image/gif';
15
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'image/webp';
16
+ fail('不支持此图片格式,当前支持 PNG、JPEG、GIF、WebP。', 415);
17
+ }
18
+ function fail(message, statusCode = 400) { throw Object.assign(new Error(message), { statusCode }); }
19
+ function canonical(value) {
20
+ return String(value || '').replaceAll('\\', '/').replace(/\/+$/, '');
21
+ }
22
+ function key(value) {
23
+ const p = canonical(value);
24
+ return /^[a-z]:\//i.test(p) || p.startsWith('//') ? p.toLowerCase() : p;
25
+ }
26
+ function inside(base, candidate) {
27
+ const a = key(base), b = key(candidate);
28
+ return b === a || b.startsWith(a + '/');
29
+ }
30
+ function relative(base, candidate) {
31
+ if (!inside(base, candidate)) return null;
32
+ return canonical(candidate).slice(canonical(base).length).replace(/^\//, '');
33
+ }
34
+ function child(base, value = '') {
35
+ if (typeof value !== 'string' || /[\\:\x00-\x1f]/.test(value) || value.startsWith('/') ||
36
+ value.split('/').some(p => p === '..' || p === '.' || (p && /[. ]$/.test(p)))) fail('无效的项目相对路径。');
37
+ return canonical(base) + (value ? '/' + value : '') || '/';
38
+ }
39
+ async function context(rpc, query) {
40
+ const threadId = query.get('threadId'), projectId = query.get('projectId');
41
+ let thread, project;
42
+ if (threadId) ({ thread } = await rpc('thread/read', { threadId, includeTurns: false }));
43
+ const id = thread?.projectId || (!threadId && projectId);
44
+ if (id && id !== '__unassigned__') ({ project } = await rpc('project/read', { projectId: id }));
45
+ const roots = [];
46
+ const add = (p, source) => {
47
+ if (typeof p !== 'string' || !p || roots.some(r => key(r.path) === key(p))) return;
48
+ roots.push({ path: p, source });
49
+ };
50
+ for (const r of project?.roots || []) add(r.path, '项目');
51
+ const environments = thread?.environments || [];
52
+ const local = environments.find(e => e.environmentId === 'local');
53
+ if (environments.length && !local) fail('当前会话使用远程执行环境,暂不支持本机文件浏览。', 409);
54
+ const cwd = local?.cwd || thread?.cwd || '';
55
+ // Runtime access roots include tool output directories, not project membership.
56
+ if (!project && cwd) add(cwd, '工作目录');
57
+ if (!roots.length) fail('请先选择一个项目或对话。', 404);
58
+ return { roots, cwd, projectName: project?.name || '', threadId };
59
+ }
60
+ function selectedRoot(context, query) {
61
+ const root = context.roots.find(r => key(r.path) === key(query.get('root')));
62
+ if (!root) fail('目录不属于当前项目或会话,请刷新后重试。', 403);
63
+ return root.path;
64
+ }
65
+ function directory(result) {
66
+ if (!Array.isArray(result.entries)) fail('工作站没有返回目录条目。', 502);
67
+ if (result.entries.length > maxEntries) fail('此目录超过 4000 项,暂不支持浏览。', 413);
68
+ const entries = result.entries.map(e => {
69
+ if (!e.fileName || /[/\\:\x00-\x1f]/.test(e.fileName) || ['.', '..'].includes(e.fileName)) fail('目录条目名称无效。', 502);
70
+ return { name: e.fileName, directory: e.isDirectory === true, file: e.isFile === true };
71
+ });
72
+ entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
73
+ return { entries };
74
+ }
75
+ function decode(bytes) {
76
+ if (bytes.length > maxBytes) fail('文件超过只读预览上限(1 MiB)。', 413);
77
+ let encoding = 'utf-8';
78
+ if (bytes[0] === 255 && bytes[1] === 254) encoding = 'utf-16le';
79
+ else if (bytes[0] === 254 && bytes[1] === 255) encoding = 'utf-16be';
80
+ let text;
81
+ try { text = new TextDecoder(encoding, { fatal: true }).decode(bytes); }
82
+ catch { fail('此文件不是可识别的 UTF-8/UTF-16 文本,暂不支持预览。', 415); }
83
+ if (/[\x00-\x08\x0e-\x1f]/.test(text)) fail('此文件包含二进制内容,暂不支持文本预览。', 415);
84
+ if (text.split('\n').length > maxLines) fail('文件超过 20000 行,暂不支持预览。', 413);
85
+ return { text, encoding, size: bytes.length };
86
+ }
87
+ function fromBase64(value) {
88
+ if (typeof value !== 'string') fail('工作站没有返回文件内容。', 502);
89
+ if (value.length > Math.ceil(maxBytes / 3) * 4) fail('文件超过只读预览上限(1 MiB)。', 413);
90
+ const { encoding, size } = decode(Uint8Array.from(atob(value), c => c.charCodeAt(0)));
91
+ return { dataBase64: value, encoding, size };
92
+ }
93
+ function diffLines(text) {
94
+ let old = 0, newer = 0, oldLeft = 0, newLeft = 0;
95
+ return text.replace(/\r\n/g, '\n').split('\n').map(text => {
96
+ const row = { text, old: '', newer: '', kind: '' };
97
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
98
+ if (hunk) {
99
+ old = Number(hunk[1]); oldLeft = Number(hunk[2] ?? 1);
100
+ newer = Number(hunk[3]); newLeft = Number(hunk[4] ?? 1); row.kind = 'hunk';
101
+ } else if (text.startsWith('+') && newLeft > 0) {
102
+ row.newer = newer++; newLeft--; row.kind = 'add';
103
+ } else if (text.startsWith('-') && oldLeft > 0) {
104
+ row.old = old++; oldLeft--; row.kind = 'remove';
105
+ } else if (text.startsWith(' ') && oldLeft > 0 && newLeft > 0) {
106
+ row.old = old++; row.newer = newer++; oldLeft--; newLeft--;
107
+ } else if (!text.startsWith('\\')) { oldLeft = 0; newLeft = 0; }
108
+ return row;
109
+ });
110
+ }
111
+ async function change(rpc, query) {
112
+ const threadId = query.get('threadId'), turnId = query.get('turnId');
113
+ const itemId = query.get('itemId'), path = query.get('path');
114
+ if (!threadId || !turnId || !itemId || !path) fail('缺少文件修改记录的定位信息。');
115
+ let cursor;
116
+ do {
117
+ // Only an explicit detail click reads raw items, within the selected turn.
118
+ const page = await rpc('thread/items/list', { threadId, turnId, limit: 100,
119
+ sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
120
+ const item = page.data?.find(entry => entry.turnId === turnId && entry.item?.id === itemId)?.item;
121
+ if (item) {
122
+ const file = item.type === 'fileChange' && item.changes?.find(value => value.path === path);
123
+ if (!file) fail('这条修改记录中没有该文件。', 404);
124
+ const diff = typeof file.diff === 'string' ? file.diff : file.diff?.text;
125
+ if (typeof diff !== 'string') fail('此条目未提供真实 diff。', 404);
126
+ if (new TextEncoder().encode(diff).length > maxBytes || diff.split('\n').length > maxLines) fail('此项 diff 超过预览上限。', 413);
127
+ return { diff, turnId };
128
+ }
129
+ if (page.nextCursor && page.nextCursor === cursor) fail('修改记录分页位置未推进。', 502);
130
+ cursor = page.nextCursor;
131
+ } while (cursor);
132
+ fail('未找到这条文件修改记录。', 404);
133
+ }
134
+ async function changes(rpc, query) {
135
+ const threadId = query.get('threadId');
136
+ if (!threadId) fail('请先选择对话以查看修改记录。');
137
+ const cursor = query.get('cursor');
138
+ const page = await rpc('thread/items/list', { threadId, limit: 20, sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
139
+ const changes = []; let diffBytes = 0;
140
+ for (const entry of page.data || []) {
141
+ if (entry.item?.type !== 'fileChange') continue;
142
+ for (const change of entry.item.changes || []) {
143
+ const diff = typeof change.diff === 'string' ? change.diff : change.diff?.text;
144
+ const bytes = typeof diff === 'string' ? new TextEncoder().encode(diff).length : 0;
145
+ const tooLarge = typeof diff === 'string' && (bytes + diffBytes > maxBytes || diff.split('\n').length > maxLines);
146
+ if (!tooLarge) diffBytes += bytes;
147
+ changes.push({ path: change.path, kind: change.kind, turnId: entry.turnId, itemId: entry.item.id,
148
+ diff: tooLarge ? null : diff ?? null, error: tooLarge ? '此项 diff 超过预览上限。' : '' });
149
+ }
150
+ }
151
+ // Return an explicit page even when it has no file changes. Never scan a whole thread implicitly.
152
+ return { changes, nextCursor: page.nextCursor || null, scope: '对话文件修改记录(按操作展示)' };
153
+ }
154
+ function isTextFile(name) {
155
+ const base = String(name || '').replaceAll('\\', '/').split('/').at(-1);
156
+ return !base.includes('.') || (base.startsWith('.') && !base.slice(1).includes('.')) || /\.(txt|log|md|markdown|rst|csv|tsv|json5?|jsonl|ipynb|ya?ml|toml|ini|cfg|conf|config|xml|xaml|svg|html?|css|scss|less|[cm]?js|jsx|ts|tsx|ets|py|rs|go|java|kt|kts|c|cc|cpp|cxx|h|hpp|cs|swift|m|mm|sh|bash|zsh|ps1|bat|cmd|sql|r|rb|php|vue|svelte|gradle|properties|cmake|make|diff|patch)$/i.test(base);
157
+ }
158
+ return { isTextFile, maxBytes, maxEntries, maxLines, maxFileBytes, mediaMime, mediaKind, imageChunkBytes, imageMime, fail, key, inside, relative, child, context, selectedRoot, directory, decode, fromBase64, diffLines, change, changes };
159
+ });
@@ -1,31 +1,31 @@
1
- (function (root, factory) {
2
- if (typeof module === 'object' && module.exports) module.exports = factory();
3
- else root.CodexFileChanges = factory();
4
- })(globalThis, function () {
5
- function lineCounts(change) {
6
- const diff = typeof change.diff === 'string' ? change.diff : change.diff?.text;
7
- if (typeof diff !== 'string') return { added: change.lineCounts?.added || 0, removed: change.lineCounts?.removed || 0 };
8
- const kind = typeof change.kind === 'string' ? change.kind : change.kind?.type;
9
- if (kind === 'add' || kind === 'delete') {
10
- // app-server sends the full file content, not a unified diff, for add/delete.
11
- const lines = diff ? diff.split('\n').length - Number(diff.endsWith('\n')) : 0;
12
- return { added: kind === 'add' ? lines : 0, removed: kind === 'delete' ? lines : 0 };
13
- }
14
- let added = 0, removed = 0, oldLeft = 0, newLeft = 0;
15
- for (const line of diff.split(/\r?\n/)) {
16
- const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
17
- if (hunk) { oldLeft = Number(hunk[1] ?? 1); newLeft = Number(hunk[2] ?? 1); }
18
- else if (line.startsWith('+') && newLeft > 0) { added++; newLeft--; }
19
- else if (line.startsWith('-') && oldLeft > 0) { removed++; oldLeft--; }
20
- else if (line.startsWith(' ') && oldLeft > 0 && newLeft > 0) { oldLeft--; newLeft--; }
21
- else if (!line.startsWith('\\')) { oldLeft = 0; newLeft = 0; }
22
- }
23
- return { added, removed };
24
- }
25
- function summarize(changes) {
26
- return (Array.isArray(changes) ? changes : []).map(change => ({
27
- path: change.path, kind: change.kind, lineCounts: lineCounts(change),
28
- }));
29
- }
30
- return { lineCounts, summarize };
31
- });
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodexFileChanges = factory();
4
+ })(globalThis, function () {
5
+ function lineCounts(change) {
6
+ const diff = typeof change.diff === 'string' ? change.diff : change.diff?.text;
7
+ if (typeof diff !== 'string') return { added: change.lineCounts?.added || 0, removed: change.lineCounts?.removed || 0 };
8
+ const kind = typeof change.kind === 'string' ? change.kind : change.kind?.type;
9
+ if (kind === 'add' || kind === 'delete') {
10
+ // app-server sends the full file content, not a unified diff, for add/delete.
11
+ const lines = diff ? diff.split('\n').length - Number(diff.endsWith('\n')) : 0;
12
+ return { added: kind === 'add' ? lines : 0, removed: kind === 'delete' ? lines : 0 };
13
+ }
14
+ let added = 0, removed = 0, oldLeft = 0, newLeft = 0;
15
+ for (const line of diff.split(/\r?\n/)) {
16
+ const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
17
+ if (hunk) { oldLeft = Number(hunk[1] ?? 1); newLeft = Number(hunk[2] ?? 1); }
18
+ else if (line.startsWith('+') && newLeft > 0) { added++; newLeft--; }
19
+ else if (line.startsWith('-') && oldLeft > 0) { removed++; oldLeft--; }
20
+ else if (line.startsWith(' ') && oldLeft > 0 && newLeft > 0) { oldLeft--; newLeft--; }
21
+ else if (!line.startsWith('\\')) { oldLeft = 0; newLeft = 0; }
22
+ }
23
+ return { added, removed };
24
+ }
25
+ function summarize(changes) {
26
+ return (Array.isArray(changes) ? changes : []).map(change => ({
27
+ path: change.path, kind: change.kind, lineCounts: lineCounts(change),
28
+ }));
29
+ }
30
+ return { lineCounts, summarize };
31
+ });
@@ -1,117 +1,117 @@
1
- (function (root, factory) {
2
- const api = factory();
3
- if (typeof module === 'object' && module.exports) module.exports = api;
4
- else root.CodexFileCitations = api;
5
- })(globalThis, function () {
6
- const prefix = ':codex-file-citation{';
7
-
8
- function parse(source, start) {
9
- if (!source.startsWith(prefix, start)) return null;
10
- const attributes = Object.create(null);
11
- let position = start + prefix.length;
12
- while (position < source.length) {
13
- while (/[ \t]/.test(source[position] || '')) position++;
14
- if (source[position] === '}') return { attributes, end: position + 1 };
15
- const key = /^[A-Za-z_][\w-]*/.exec(source.slice(position));
16
- if (!key) return null;
17
- position += key[0].length;
18
- const afterKey = position;
19
- while (/[ \t]/.test(source[position] || '')) position++;
20
- let value = '';
21
- const assigned = source[position] === '=';
22
- if (assigned) {
23
- position++;
24
- while (/[ \t]/.test(source[position] || '')) position++;
25
- const quote = source[position];
26
- if (quote === '"' || quote === "'") {
27
- position++;
28
- while (position < source.length && source[position] !== quote) {
29
- if (/[\r\n]/.test(source[position])) return null;
30
- if (source[position] === '\\' && source[position + 1] === quote) position++;
31
- value += source[position++];
32
- }
33
- if (source[position++] !== quote) return null;
34
- } else {
35
- const match = /^[^\s{}"']+/.exec(source.slice(position));
36
- if (!match) return null;
37
- value = match[0];
38
- position += value.length;
39
- }
40
- }
41
- attributes[key[0]] = value;
42
- if ((assigned || position === afterKey) && source[position] !== '}' && !/[ \t]/.test(source[position] || '')) return null;
43
- }
44
- return null;
45
- }
46
-
47
- function positiveLine(value) {
48
- const line = /^\d+$/.test(value || '') ? Number(value) : 0;
49
- return Number.isSafeInteger(line) && line > 0 ? line : null;
50
- }
51
-
52
- function localReference(source) {
53
- if (typeof source !== 'string' || /[\x00-\x1f]/.test(source)) return null;
54
- let reference = source, fragment = '';
55
- if (/^file:/i.test(reference)) {
56
- if (!/^file:\//i.test(reference)) return null;
57
- let url;
58
- try { url = new URL(reference); } catch { return null; }
59
- // A file URL names the connected host's local filesystem, not a network share.
60
- if ((url.hostname && url.hostname.toLowerCase() !== 'localhost') || url.search) return null;
61
- reference = url.pathname;
62
- fragment = url.hash;
63
- } else {
64
- const anchor = reference.match(/#L\d+(?:C\d+)?(?:-L\d+)?$/i);
65
- if (anchor) { fragment = anchor[0]; reference = reference.slice(0, anchor.index); }
66
- }
67
- const position = reference.match(/:(\d+)(?::(\d+))?$/);
68
- if (position) reference = reference.slice(0, position.index);
69
- const anchor = /^#L(\d+)(?:C(\d+))?(?:-L(\d+))?$/i.exec(fragment);
70
- if (fragment && !anchor) return null;
71
- const line = positiveLine(anchor?.[1] ?? position?.[1]);
72
- const column = positiveLine(anchor ? anchor[2] : position?.[2]);
73
- const end = positiveLine(anchor?.[3]);
74
- // Decode only URL syntax, once; preserve encoded punctuation in actual filenames.
75
- try { reference = decodeURIComponent(reference); } catch { return null; }
76
- reference = reference.replace(/^\/(?=[a-z]:[\\/])/i, '');
77
- if (reference.replaceAll('\\', '/').startsWith('//')) return null;
78
- if (/[\x00-\x1f]/.test(reference) || !/^(?:[a-z]:[\\/]|\/(?!\/))/i.test(reference)) return null;
79
- return { path: reference, line, column, end: line && end >= line ? end : null };
80
- }
81
-
82
- function install(markdown) {
83
- const validateLink = markdown.validateLink.bind(markdown);
84
- markdown.validateLink = value => /^file:/i.test(value) ? localReference(value) !== null : validateLink(value);
85
- markdown.inline.ruler.before('text', 'codex_file_citation', (state, silent) => {
86
- if (!state.env?.allowFileCitations || state.linkLevel > 0) return false;
87
- const result = parse(state.src, state.pos);
88
- const attributes = result?.attributes;
89
- const path = attributes?.path?.trim();
90
- if (!path || /[\0\r\n]/.test(path)) return false;
91
- const fileUrl = /^file:/i.test(path) ? localReference(path) : null;
92
- const local = fileUrl !== null || /^(?:[a-z]:[\\/]|\/(?!\/))/i.test(path);
93
- if (!local && !/^https?:\/\//i.test(path)) return false;
94
- const filePath = fileUrl?.path || path;
95
- const line = local ? positiveLine(attributes.lineRangeStart ?? attributes.line_range_start ?? fileUrl?.line) : null;
96
- const end = local ? positiveLine(attributes.lineRangeEnd ?? attributes.line_range_end ?? fileUrl?.end) : null;
97
- // A citation path is a literal filename; location belongs to its own fields.
98
- const reference = local ? encodeURI(filePath).replace(/[:?#]/g, encodeURIComponent) + (line ? `:${line}` : '') : path;
99
- const href = markdown.normalizeLink(reference);
100
- if (!markdown.validateLink(href)) return false;
101
- if (!silent) {
102
- const link = state.push('link_open', 'a', 1);
103
- link.attrSet('href', href);
104
- link.attrSet('title', path);
105
- link.meta = { fileCitation: { path: filePath, line, end: line && end >= line ? end : null, purpose: attributes.purpose } };
106
- const label = state.push('text', '', 0);
107
- label.content = local ? filePath.split(/[\\/]/).filter(Boolean).at(-1) || filePath
108
- : attributes.label || attributes.title || path;
109
- state.push('link_close', 'a', -1);
110
- }
111
- state.pos = result.end;
112
- return true;
113
- });
114
- }
115
-
116
- return Object.freeze({ install, localReference });
117
- });
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ else root.CodexFileCitations = api;
5
+ })(globalThis, function () {
6
+ const prefix = ':codex-file-citation{';
7
+
8
+ function parse(source, start) {
9
+ if (!source.startsWith(prefix, start)) return null;
10
+ const attributes = Object.create(null);
11
+ let position = start + prefix.length;
12
+ while (position < source.length) {
13
+ while (/[ \t]/.test(source[position] || '')) position++;
14
+ if (source[position] === '}') return { attributes, end: position + 1 };
15
+ const key = /^[A-Za-z_][\w-]*/.exec(source.slice(position));
16
+ if (!key) return null;
17
+ position += key[0].length;
18
+ const afterKey = position;
19
+ while (/[ \t]/.test(source[position] || '')) position++;
20
+ let value = '';
21
+ const assigned = source[position] === '=';
22
+ if (assigned) {
23
+ position++;
24
+ while (/[ \t]/.test(source[position] || '')) position++;
25
+ const quote = source[position];
26
+ if (quote === '"' || quote === "'") {
27
+ position++;
28
+ while (position < source.length && source[position] !== quote) {
29
+ if (/[\r\n]/.test(source[position])) return null;
30
+ if (source[position] === '\\' && source[position + 1] === quote) position++;
31
+ value += source[position++];
32
+ }
33
+ if (source[position++] !== quote) return null;
34
+ } else {
35
+ const match = /^[^\s{}"']+/.exec(source.slice(position));
36
+ if (!match) return null;
37
+ value = match[0];
38
+ position += value.length;
39
+ }
40
+ }
41
+ attributes[key[0]] = value;
42
+ if ((assigned || position === afterKey) && source[position] !== '}' && !/[ \t]/.test(source[position] || '')) return null;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function positiveLine(value) {
48
+ const line = /^\d+$/.test(value || '') ? Number(value) : 0;
49
+ return Number.isSafeInteger(line) && line > 0 ? line : null;
50
+ }
51
+
52
+ function localReference(source) {
53
+ if (typeof source !== 'string' || /[\x00-\x1f]/.test(source)) return null;
54
+ let reference = source, fragment = '';
55
+ if (/^file:/i.test(reference)) {
56
+ if (!/^file:\//i.test(reference)) return null;
57
+ let url;
58
+ try { url = new URL(reference); } catch { return null; }
59
+ // A file URL names the connected host's local filesystem, not a network share.
60
+ if ((url.hostname && url.hostname.toLowerCase() !== 'localhost') || url.search) return null;
61
+ reference = url.pathname;
62
+ fragment = url.hash;
63
+ } else {
64
+ const anchor = reference.match(/#L\d+(?:C\d+)?(?:-L\d+)?$/i);
65
+ if (anchor) { fragment = anchor[0]; reference = reference.slice(0, anchor.index); }
66
+ }
67
+ const position = reference.match(/:(\d+)(?::(\d+))?$/);
68
+ if (position) reference = reference.slice(0, position.index);
69
+ const anchor = /^#L(\d+)(?:C(\d+))?(?:-L(\d+))?$/i.exec(fragment);
70
+ if (fragment && !anchor) return null;
71
+ const line = positiveLine(anchor?.[1] ?? position?.[1]);
72
+ const column = positiveLine(anchor ? anchor[2] : position?.[2]);
73
+ const end = positiveLine(anchor?.[3]);
74
+ // Decode only URL syntax, once; preserve encoded punctuation in actual filenames.
75
+ try { reference = decodeURIComponent(reference); } catch { return null; }
76
+ reference = reference.replace(/^\/(?=[a-z]:[\\/])/i, '');
77
+ if (reference.replaceAll('\\', '/').startsWith('//')) return null;
78
+ if (/[\x00-\x1f]/.test(reference) || !/^(?:[a-z]:[\\/]|\/(?!\/))/i.test(reference)) return null;
79
+ return { path: reference, line, column, end: line && end >= line ? end : null };
80
+ }
81
+
82
+ function install(markdown) {
83
+ const validateLink = markdown.validateLink.bind(markdown);
84
+ markdown.validateLink = value => /^file:/i.test(value) ? localReference(value) !== null : validateLink(value);
85
+ markdown.inline.ruler.before('text', 'codex_file_citation', (state, silent) => {
86
+ if (!state.env?.allowFileCitations || state.linkLevel > 0) return false;
87
+ const result = parse(state.src, state.pos);
88
+ const attributes = result?.attributes;
89
+ const path = attributes?.path?.trim();
90
+ if (!path || /[\0\r\n]/.test(path)) return false;
91
+ const fileUrl = /^file:/i.test(path) ? localReference(path) : null;
92
+ const local = fileUrl !== null || /^(?:[a-z]:[\\/]|\/(?!\/))/i.test(path);
93
+ if (!local && !/^https?:\/\//i.test(path)) return false;
94
+ const filePath = fileUrl?.path || path;
95
+ const line = local ? positiveLine(attributes.lineRangeStart ?? attributes.line_range_start ?? fileUrl?.line) : null;
96
+ const end = local ? positiveLine(attributes.lineRangeEnd ?? attributes.line_range_end ?? fileUrl?.end) : null;
97
+ // A citation path is a literal filename; location belongs to its own fields.
98
+ const reference = local ? encodeURI(filePath).replace(/[:?#]/g, encodeURIComponent) + (line ? `:${line}` : '') : path;
99
+ const href = markdown.normalizeLink(reference);
100
+ if (!markdown.validateLink(href)) return false;
101
+ if (!silent) {
102
+ const link = state.push('link_open', 'a', 1);
103
+ link.attrSet('href', href);
104
+ link.attrSet('title', path);
105
+ link.meta = { fileCitation: { path: filePath, line, end: line && end >= line ? end : null, purpose: attributes.purpose } };
106
+ const label = state.push('text', '', 0);
107
+ label.content = local ? filePath.split(/[\\/]/).filter(Boolean).at(-1) || filePath
108
+ : attributes.label || attributes.title || path;
109
+ state.push('link_close', 'a', -1);
110
+ }
111
+ state.pos = result.end;
112
+ return true;
113
+ });
114
+ }
115
+
116
+ return Object.freeze({ install, localReference });
117
+ });