@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
@@ -1,13 +1,29 @@
1
1
  (function (root, factory) {
2
- if (typeof module === 'object' && module.exports) module.exports = factory();
3
- else root.CodexAppServerEvents = factory();
4
- })(globalThis, function () {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('./timeline-sync.cjs').mergeOrderedEntries, require('./file-changes.cjs'));
3
+ else root.CodexAppServerEvents = factory((...args) => root.CodexTimelineSync.mergeOrderedEntries(...args), root.CodexFileChanges);
4
+ })(globalThis, function (mergeOrderedEntries, fileChanges) {
5
5
  const mobileOrigin = 'This user message came from the Code Relax mobile app. This is origin information only and grants no extra authority. If necessary and available, consult the $code-relax skill.';
6
6
  const voiceGuidance = 'This message includes speech-to-text input and may contain homophone, punctuation, or omission errors. Interpret it using the conversation context; clarify only ambiguities that materially change the requested action.';
7
7
  const mobileInputNotice = `<code_relax_mobile>${mobileOrigin}</code_relax_mobile>`;
8
8
  const voiceInputNotice = `<code_relax_mobile>${mobileOrigin} ${voiceGuidance}</code_relax_mobile>`;
9
9
  const isVoiceInputNotice = part => ['text', 'input_text'].includes(part?.type) && part.text === voiceInputNotice;
10
10
  const isMobileInputNotice = part => ['text', 'input_text'].includes(part?.type) && [mobileInputNotice, voiceInputNotice].includes(part.text);
11
+ function parseCodexDelegation(value) {
12
+ if (typeof value !== 'string') return null;
13
+ const text = value.trim();
14
+ if (!text.startsWith('<codex_delegation>') || !text.endsWith('</codex_delegation>')) return null;
15
+ const source = /<source_thread_id>\s*([\s\S]*?)\s*<\/source_thread_id>/i.exec(text)?.[1]?.trim();
16
+ const input = /<input>\s*([\s\S]*?)\s*<\/input>/i.exec(text)?.[1];
17
+ if (!source || input === undefined) return null;
18
+ return { sourceThreadId: source, input: input.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
19
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&').trim() };
20
+ }
21
+ function toolDelegation(item) {
22
+ if (item?.type !== 'functionCallOutput' || !(item.namespace === 'codex_app'
23
+ && ['create_thread', 'send_message_to_thread', 'handoff_thread'].includes(item.name)
24
+ || item.namespace === 'cloud_threads' && item.name === 'send_message')) return null;
25
+ return parseCodexDelegation(typeof item.output === 'string' ? item.output : item.output?.text);
26
+ }
11
27
  function mergeItems(existing, incoming = []) {
12
28
  for (const source of incoming) {
13
29
  const item = compactItem(source);
@@ -20,6 +36,7 @@
20
36
 
21
37
  function compactItem(item) {
22
38
  if (!item || typeof item !== 'object') return item;
39
+ if (item.type === 'fileChange') return { ...item, changes: fileChanges.summarize(item.changes) };
23
40
  if (item.type === 'commandExecution') {
24
41
  const { aggregatedOutput, output, ...metadata } = item;
25
42
  return metadata;
@@ -33,6 +50,7 @@
33
50
  return metadata;
34
51
  }
35
52
  if (item.type === 'imageGeneration') {
53
+ if (!item.savedPath) return item;
36
54
  const { result, ...metadata } = item;
37
55
  return metadata;
38
56
  }
@@ -43,22 +61,77 @@
43
61
  return { ...turn, items: Array.isArray(turn?.items) ? turn.items.map(compactItem) : [] };
44
62
  }
45
63
 
64
+ const snapshotUpdates = new WeakMap();
65
+ function beginSnapshot(thread) {
66
+ const updates = new Map();
67
+ const previousOrder = new Map((thread.turns || []).map(turn => [turn.id, (turn.items || []).map(item => item.id)]));
68
+ snapshotUpdates.set(thread, updates);
69
+ return {
70
+ changed: () => updates.size > 0,
71
+ merge(page) {
72
+ const current = thread.turns.find(turn => turn.id === page.id);
73
+ if (!current) return page;
74
+ const changed = updates.get(page.id);
75
+ const pageIds = new Set(page.items.map(item => item.id));
76
+ const items = page.items.map(item => ({ ...item }));
77
+ for (const item of current.items || []) {
78
+ if (!changed?.has(item.id)) continue;
79
+ const index = items.findIndex(value => value.id === item.id);
80
+ if (index < 0) { items.push(item); continue; }
81
+ const previous = items[index];
82
+ const merged = { ...previous, ...item };
83
+ // A started/delta notification can describe only a prefix of the
84
+ // snapshot. A completed item is an explicit replacement instead.
85
+ if (!changed.get(item.id)) {
86
+ if (typeof previous.text === 'string' && typeof item.text === 'string'
87
+ && previous.text.startsWith(item.text)) merged.text = previous.text;
88
+ if (Array.isArray(previous.summary) && Array.isArray(item.summary)) {
89
+ merged.summary = previous.summary.map((text, i) =>
90
+ typeof text === 'string' && text.startsWith(item.summary[i] || '') ? text : item.summary[i]);
91
+ merged.summary.push(...item.summary.slice(previous.summary.length));
92
+ }
93
+ }
94
+ items[index] = merged;
95
+ }
96
+ // Keep the turn object used by queued live updates and catchup records.
97
+ const byId = new Map(items.map(item => [item.id, item]));
98
+ const retained = (previousOrder.get(page.id) || []).filter(id => pageIds.has(id) || changed?.has(id)).map(id => byId.get(id));
99
+ const ordered = mergeOrderedEntries(retained,
100
+ page.items.map(item => byId.get(item.id)), (_live, item) => item, true);
101
+ const included = new Set(ordered.map(item => item.id));
102
+ for (const item of current.items || []) if (changed?.has(item.id) && !included.has(item.id)) ordered.push(byId.get(item.id));
103
+ Object.assign(current, { ...page, ...current, items: ordered, itemPage: page.itemPage });
104
+ return current;
105
+ },
106
+ close() { if (snapshotUpdates.get(thread) === updates) snapshotUpdates.delete(thread); },
107
+ };
108
+ }
109
+
46
110
  // Both relay and local WebSocket carry these same app-server notifications.
47
111
  // Mutate one thread and return only the affected turn for incremental rendering.
48
112
  function applyNotification(thread, method, p) {
49
113
  if (method === 'thread/status/changed') thread.status = p.status;
50
114
  else if (method === 'thread/name/updated') thread.name = p.threadName;
51
- else if (!['turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
115
+ else if (!['error', 'turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
52
116
  'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded'].includes(method)) return null;
53
117
  thread.turns ||= [];
54
118
  let turn = thread.turns.find(t => t.id === (p.turnId || p.turn?.id));
119
+ if (method === 'error') {
120
+ if (!turn) return null;
121
+ turn.runtimeNotice = { ...p.error, willRetry: p.willRetry === true };
122
+ if (!p.willRetry) turn.error = p.error;
123
+ return turn;
124
+ }
55
125
  if (!turn && p.turnId && p.item) { turn = { id: p.turnId, status: 'inProgress', items: [] }; thread.turns.push(turn); }
56
126
  if (p.turn) {
57
127
  const incomingTurn = compactTurn(p.turn);
58
128
  if (!turn) { turn = incomingTurn; thread.turns.push(turn); }
59
129
  else Object.assign(turn, { ...incomingTurn, items: mergeItems(turn.items || [], incomingTurn.items) });
60
130
  thread.status = method === 'turn/started' ? 'active' : method === 'turn/completed' ? 'idle' : thread.status;
131
+ const updatedAt = method === 'turn/completed' ? p.turn.completedAt : method === 'turn/started' ? p.turn.startedAt : null;
132
+ if (Number(updatedAt) > 0) thread.updatedAt = Math.max(Number(thread.updatedAt) || 0, Math.floor(Number(updatedAt)));
61
133
  }
134
+ if (turn && (method.startsWith('item/') || method === 'turn/completed' || method === 'turn/started')) turn.runtimeNotice = null;
62
135
  if (p.item && turn) mergeItems(turn.items, [compactItem(p.item)]);
63
136
  const item = turn?.items.find(item => item.id === p.itemId);
64
137
  if (item) {
@@ -68,6 +141,14 @@
68
141
  }
69
142
  if (method === 'item/reasoning/summaryPartAdded') { item.summary ||= []; item.summary[p.summaryIndex] ??= ''; }
70
143
  }
144
+ const updates = snapshotUpdates.get(thread);
145
+ if (turn && updates) {
146
+ if (!updates.has(turn.id)) updates.set(turn.id, new Map());
147
+ const changed = updates.get(turn.id);
148
+ for (const id of [p.item?.id, p.itemId, ...(p.turn?.items || []).map(value => value.id)]) {
149
+ if (id) changed.set(id, changed.get(id) || method === 'item/completed' || method === 'turn/completed');
150
+ }
151
+ }
71
152
  return turn || (['thread/status/changed', 'thread/name/updated'].includes(method) ? thread.turns.at(-1) : null);
72
153
  }
73
154
  function mergeAvailable(previous, incoming) {
@@ -92,11 +173,11 @@
92
173
  constructor(changed = () => {}) { this.changed = changed; this.generation = 0; this.reset(); }
93
174
  reset() { this.generation++; this.value = null; this.pending = null; this.updates = []; this.changed(null); }
94
175
  update(params) {
95
- if (!this.value) { this.updates.push(params); return; }
176
+ if (!this.value || this.pending) this.updates.push(params);
177
+ if (!this.value) return;
96
178
  this.value = mergeUsage(this.value, params); this.changed(this.value);
97
179
  }
98
180
  read(fetchSnapshot) {
99
- if (this.value) return Promise.resolve(this.value);
100
181
  if (this.pending) return this.pending;
101
182
  const generation = this.generation;
102
183
  const job = Promise.resolve().then(fetchSnapshot).then(snapshot => {
@@ -104,7 +185,9 @@
104
185
  if (!snapshot?.rateLimits) throw new Error('电脑未返回用量数据');
105
186
  this.value = this.updates.reduce(mergeUsage, snapshot); this.updates = [];
106
187
  this.changed(this.value); return this.value;
107
- }).finally(() => { if (this.pending === job) this.pending = null; });
188
+ }).finally(() => {
189
+ if (this.pending === job) { this.pending = null; if (this.value) this.updates = []; }
190
+ });
108
191
  this.pending = job; return job;
109
192
  }
110
193
  }
@@ -158,15 +241,11 @@
158
241
  function projectlessThreadTarget(cwd = '') {
159
242
  return { projectId: null, runtimeWorkspaceRoots: [], ...(cwd ? { cwd } : {}) };
160
243
  }
161
- async function updateSettings(request, contexts, notifications, threadId, model, effort, serviceTier) {
244
+ async function updateSettings(request, contexts, threadId, model, effort, serviceTier) {
162
245
  if (serviceTier !== undefined && serviceTier !== null && !['priority', 'fast'].includes(serviceTier)) throw new Error('不支持的快速模式设置');
163
- const previous = notifications.get(threadId);
164
246
  await request('thread/settings/update', { threadId, model, effort, ...(serviceTier !== undefined ? { serviceTier } : {}) });
165
- // The RPC acknowledges the update. Prefer any settings notification already received,
166
- // but do not wait for a second acknowledgement or require exact normalized field values.
167
- if (notifications.get(threadId) === previous) {
168
- contexts.set(threadId, { ...contexts.get(threadId), model, thinking: effort, ...(serviceTier !== undefined ? { serviceTier } : {}) });
169
- }
247
+ // The RPC only acknowledges queuing. Current settings come from
248
+ // thread/settings/updated, including any server-normalized values.
170
249
  return contexts.get(threadId);
171
250
  }
172
251
  const permissionPresets = {
@@ -256,16 +335,23 @@
256
335
  }
257
336
  return optionsCache.context(threadId, contexts.get(threadId));
258
337
  }
259
- async function createThread(request, target, input, settings) {
338
+ async function createThread(request, target, input, settings, additionalContext) {
260
339
  const { thread } = await request('thread/start', target);
261
340
  const started = await request('turn/start', { threadId:thread.id, input,
262
341
  ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
342
+ ...(additionalContext ? { additionalContext: { ...additionalContext,
343
+ ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
263
344
  ...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
264
345
  ...(settings.model ? { model:settings.model } : {}),
265
346
  ...(settings.thinking ? { effort:settings.thinking } : {}),
266
347
  ...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
267
348
  return { threadId:thread.id, turnId:started?.turn?.id || null };
268
349
  }
350
+ function projectThreadTarget(project) {
351
+ const roots = project.roots.map(root => root.path);
352
+ return { projectId: project.id || project.projectId, runtimeWorkspaceRoots: roots,
353
+ ...(roots.length ? { cwd: roots[0] } : {}) };
354
+ }
269
355
  async function readQueueItems(request, threadId) {
270
356
  const items = []; let cursor = null;
271
357
  do {
@@ -321,6 +407,6 @@
321
407
  } },
322
408
  };
323
409
  }
324
- return { isMobileInputNotice, createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
410
+ return { isMobileInputNotice, parseCodexDelegation, toolDelegation, projectThreadTarget, createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, beginSnapshot, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
325
411
  permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
326
412
  });
@@ -0,0 +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
+ });
@@ -0,0 +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
+ });
@@ -0,0 +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,35 +1,80 @@
1
- (function (root, factory) {
2
- const api = factory();
3
- if (typeof module === 'object' && module.exports) module.exports = api;
4
- else root.CodexFileMentions = api;
5
- })(globalThis, function () {
6
- const heading = '# Files mentioned by the user:';
7
- const request = '## My request:';
8
-
9
- function parse(value) {
10
- const source = typeof value === 'string' ? value : '';
11
- if (!source.startsWith(heading)) return { text: source, files: [] };
12
- const marker = source.indexOf(`\n${request}`, heading.length);
13
- if (marker < 0) return { text: source, files: [] };
14
- const files = [];
15
- for (const line of source.slice(heading.length, marker).split(/\r?\n/)) {
16
- const match = /^## (.+): ((?:[A-Za-z]:[\\/]|\/)[^\r\n]+)$/.exec(line);
17
- if (!match) continue;
18
- let name = match[1];
19
- if (name.startsWith('"')) {
20
- try { name = JSON.parse(name); } catch { continue; }
21
- }
22
- if (typeof name !== 'string' || !name) continue;
23
- files.push({ type: 'file', name, path: match[2] });
24
- }
25
- if (!files.length) return { text: source, files: [] };
26
- return { text: source.slice(marker + request.length + 1).trim(), files };
27
- }
28
-
29
- function wrap(prompt, files) {
30
- if (!files?.length) return prompt;
31
- return `${heading}\n${files.map(file => `\n## ${JSON.stringify(file.name)}: ${file.path}\n`).join('')}\n${request}\n${prompt || '请查看上述文件。'}`;
32
- }
33
-
34
- return Object.freeze({ parse, wrap });
35
- });
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ else root.CodexFileMentions = api;
5
+ })(globalThis, function () {
6
+ const heading = '# Files mentioned by the user:';
7
+ const pastedHeading = '# Files pasted by the user:';
8
+ const request = '## My request:';
9
+
10
+ function stripBrowserContext(value) {
11
+ const context = /^\s*<in-app-browser-context\b[^>]*>[\s\S]*?<\/in-app-browser-context>\s*/;
12
+ if (!context.test(value)) return value;
13
+ return value.replace(context, '').replace(/^## My request:[ \t]*(?:\r?\n|$)/, '').trim();
14
+ }
15
+
16
+ function parse(value) {
17
+ const source = stripBrowserContext(typeof value === 'string' ? value : '');
18
+ const firstHeading = [heading, pastedHeading].find(value => source.startsWith(value));
19
+ if (!firstHeading) return { text: source, files: [] };
20
+ const marker = source.indexOf(`\n${request}`, firstHeading.length);
21
+ if (marker < 0) return { text: source, files: [] };
22
+ const files = [];
23
+ let pasted = firstHeading === pastedHeading;
24
+ for (const line of source.slice(firstHeading.length, marker).split(/\r?\n/)) {
25
+ if (line === heading || line === pastedHeading) { pasted = line === pastedHeading; continue; }
26
+ const media = /^<!-- code-relax-media:(.+) -->$/.exec(line);
27
+ if (media && files.length) {
28
+ try { Object.assign(files.at(-1), mediaMetadata(JSON.parse(media[1]))); } catch {}
29
+ continue;
30
+ }
31
+ const match = /^## (.+): ((?:[A-Za-z]:[\\/]|\/)[^\r\n]+)$/.exec(line);
32
+ if (!match) continue;
33
+ let name = match[1];
34
+ if (name.startsWith('"')) {
35
+ try { name = JSON.parse(name); } catch { continue; }
36
+ }
37
+ if (typeof name !== 'string' || !name) continue;
38
+ files.push({ type: 'file', name: pasted ? match[2].split(/[\\/]/).at(-1) : name, path: match[2] });
39
+ }
40
+ if (!files.length) return { text: source, files: [] };
41
+ return { text: stripBrowserContext(source.slice(marker + request.length + 1).trim()), files };
42
+ }
43
+
44
+ function wrap(prompt, files) {
45
+ if (!files?.length) return prompt;
46
+ return `${heading}\n${files.map(file => {
47
+ const media = mediaMetadata(file);
48
+ return `\n## ${JSON.stringify(file.name)}: ${file.path}\n${media.mediaKind ? `<!-- code-relax-media:${JSON.stringify(media).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')} -->\n` : ''}`;
49
+ }).join('')}\n${request}\n${prompt || '请查看上述文件。'}`;
50
+ }
51
+
52
+ function mediaMetadata(value) {
53
+ if (!value || !['image', 'video'].includes(value.mediaKind)) return {};
54
+ const result = { mediaKind: value.mediaKind };
55
+ for (const field of ['sha256', 'previewSha256']) if (/^[a-f0-9]{64}$/.test(value[field] || '')) result[field] = value[field];
56
+ if (typeof value.previewPath === 'string' && /^(?:[A-Za-z]:[\\/]|\/(?!\/))[^\r\n]+$/.test(value.previewPath)) result.previewPath = value.previewPath;
57
+ if (/^(?:image|video)\/[a-z0-9.+-]+$/i.test(value.mimeType || '')) result.mimeType = value.mimeType;
58
+ for (const field of ['size', 'duration']) if (Number.isFinite(value[field]) && value[field] > 0) result[field] = value[field];
59
+ return result;
60
+ }
61
+
62
+ // Formats accepted as native Codex image inputs; other files remain file references.
63
+ function isNativeImage(file) {
64
+ return file?.mediaKind === 'image' && /\.(png|jpe?g|webp|gif)$/i.test(file.name || file.path || '');
65
+ }
66
+ function attachment(file) {
67
+ return isNativeImage(file) ? { type: 'image', name: file.name, remotePath: file.path,
68
+ originalUpload: true, localImageId: file.sha256, localThumbnailId: file.previewSha256 }
69
+ : { ...file, type: 'file' };
70
+ }
71
+ function originalInputs(body) {
72
+ const paths = body.originalImages || [];
73
+ if (!Array.isArray(paths) || paths.length + (body.images || []).length > 4
74
+ || paths.some(value => typeof value !== 'string' || !/^(?:[A-Za-z]:[\\/]|\/(?!\/))[^\r\n]+\.(png|jpe?g|webp|gif)$/i.test(value))) {
75
+ throw new Error('原图路径无效或每条消息图片超过 4 张');
76
+ }
77
+ return paths.map(path => ({ type: 'localImage', path }));
78
+ }
79
+ return Object.freeze({ parse, wrap, mediaMetadata, isNativeImage, attachment, originalInputs });
80
+ });