codex-overleaf-link 2.5.1 → 2.6.5

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 (111) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +148 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -12,6 +12,7 @@ const Contract = require('./bootstrapMigrationContract');
12
12
  const { updateError } = require('./updateTrust');
13
13
 
14
14
  const BOOTSTRAP_DEADLINE_MS = 120000;
15
+ const MAX_AUTOMATIC_ROLLBACK_ATTEMPTS = 3;
15
16
  const BOOTSTRAP_STATES = new Set(['bootstrap_pending', 'bootstrap_rolling_back']);
16
17
  const backupRoot = context => path.join(context.updatesRoot, 'bootstrap-migrations');
17
18
  const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
@@ -157,7 +158,7 @@ function restoreBootstrap(context, journal) {
157
158
  if (journal.bootstrapMigration && journal.bootstrapMigration.phase !== 'pending') {
158
159
  throw updateError('update_bootstrap_record_missing', 'Bootstrap recovery material is missing.');
159
160
  }
160
- if (journal.bootstrapMigration) return assertSourceBootstrap(context, journal);
161
+ if (journal.bootstrapMigration) return untouchedBootstrap(context, journal);
161
162
  }
162
163
  return plan ? Files.restoreBootstrapMigration(plan) : null;
163
164
  }
@@ -169,29 +170,64 @@ function bootstrapEvidence(context, journal, expected) {
169
170
  || journal.bootstrapMigration.phase === 'restored' && journal.bootstrapMigration.sourceUnchanged === true)) {
170
171
  throw updateError('update_bootstrap_record_missing', 'Bootstrap recovery material is missing.');
171
172
  }
172
- return assertSourceBootstrap(context, journal);
173
+ return untouchedBootstrap(context, journal);
173
174
  }
174
175
  return plan ? Files.verifyBootstrapMigration(plan, { expected }) : null;
175
176
  }
176
177
 
177
- function assertSourceBootstrap(context, journal) {
178
- for (const [relative, expected] of Object.entries(Contract.BOOTSTRAP_MIGRATION_BASELINE)) {
179
- const filename = path.join(context.extensionRoot, relative);
180
- if (expected === null ? fs.existsSync(filename)
181
- : !fs.existsSync(filename) || !fs.lstatSync(filename).isFile() || hash(fs.readFileSync(filename)) !== expected) {
182
- throw updateError('update_bootstrap_source_mismatch', 'Bootstrap source bytes cannot be confirmed.');
178
+ // Without a file plan this transaction never wrote Bootstrap, so recovery only
179
+ // leaves it in place. Published source bytes keep the baseline digest; any other
180
+ // installed bytes (an earlier release candidate or local build) are recorded as
181
+ // observed. Later reads compare the same digest, so changes still block.
182
+ function untouchedBootstrap(context, journal) {
183
+ let knownSource = true;
184
+ const entries = Object.entries(Contract.BOOTSTRAP_MIGRATION_BASELINE).sort((a, b) => a[0].localeCompare(b[0]))
185
+ .map(([relative, expected]) => {
186
+ const actual = readBootstrapFileHash(context, relative);
187
+ if (actual !== expected) knownSource = false;
188
+ return [relative, actual];
189
+ });
190
+ return { transactionId: journal.id, migrationId: 'bootstrap-v3.1-from-2.5.0',
191
+ state: 'restored', expected: 'source', digest: hash(JSON.stringify(entries)), unmodified: true, knownSource };
192
+ }
193
+
194
+ function readBootstrapFileHash(context, relative) {
195
+ const filename = path.join(context.extensionRoot, relative);
196
+ if (!fs.existsSync(filename)) return null;
197
+ if (!fs.lstatSync(filename).isFile()) {
198
+ throw updateError('update_bootstrap_source_mismatch', 'Bootstrap source bytes cannot be confirmed.');
199
+ }
200
+ return hash(fs.readFileSync(filename));
201
+ }
202
+
203
+ // Runs before staging and in the update check. A signed migration starts only
204
+ // from the exact source bytes it names; other installations need a full
205
+ // reinstall instead of a transaction that cannot finish. This reads only the
206
+ // descriptor's file list, so later migration contracts use the same check.
207
+ function assertMigrationSource(context, manifest, sourceVersion) {
208
+ const descriptor = manifest?.bootstrapMigration;
209
+ if (!descriptor || !Array.isArray(descriptor.sourceVersions) || !descriptor.sourceVersions.includes(sourceVersion)
210
+ || !Array.isArray(descriptor.files)) return;
211
+ for (const file of descriptor.files) {
212
+ const relative = String(file?.path || '');
213
+ if (!/^bootstrap\/[A-Za-z0-9._-]+$/.test(relative)) {
214
+ throw updateError('update_bootstrap_manifest_mismatch', 'Bootstrap migration file identity is invalid.');
215
+ }
216
+ if (readBootstrapFileHash(context, relative) !== (file.sourceSha256 ?? null)) {
217
+ throw updateError('update_bootstrap_source_mismatch',
218
+ 'The installed startup files do not match the published source of this update.');
183
219
  }
184
220
  }
185
- const entries = Object.entries(Contract.BOOTSTRAP_MIGRATION_BASELINE).sort((a, b) => a[0].localeCompare(b[0]));
186
- return { transactionId: journal.id, migrationId: 'bootstrap-v3.1-from-2.5.0',
187
- state: 'restored', expected: 'source', digest: hash(JSON.stringify(entries)), unmodified: true };
188
221
  }
189
222
 
190
223
  function bootstrapExpired(journal, { recoveryRequested = false, context } = {}) {
191
224
  const migration = journal?.bootstrapMigration;
192
225
  if (!migration || !['bootstrap_pending', 'bootstrap_rolling_back', 'awaiting_health'].includes(journal.state)) return false;
193
- return journal.state === 'bootstrap_rolling_back'
194
- || (migration.deadline && Date.now() >= migration.deadline)
226
+ // Automatic retries are bounded; an explicit update.rollback still runs.
227
+ if (journal.state === 'bootstrap_rolling_back') {
228
+ return Number(journal.rollbackFailure?.attempts || 0) < MAX_AUTOMATIC_ROLLBACK_ATTEMPTS;
229
+ }
230
+ return (migration.deadline && Date.now() >= migration.deadline)
195
231
  || (recoveryRequested && migration.phase !== 'pending' && !watchdogAlive(migration, context, journal));
196
232
  }
197
233
 
@@ -228,6 +264,6 @@ function startWatchdog(context, journal, token) {
228
264
 
229
265
  function readJson(filename) { try { return JSON.parse(fs.readFileSync(filename, 'utf8')); } catch (_) { return null; } }
230
266
 
231
- module.exports = { BOOTSTRAP_DEADLINE_MS, BOOTSTRAP_STATES, migrationHint, bindBootstrapMigration,
232
- advanceBootstrapMigration, assertBootstrapConfirmation, restoreBootstrap, bootstrapEvidence,
233
- bootstrapExpired, watchdogAlive };
267
+ module.exports = { BOOTSTRAP_DEADLINE_MS, BOOTSTRAP_STATES, MAX_AUTOMATIC_ROLLBACK_ATTEMPTS, migrationHint,
268
+ bindBootstrapMigration, advanceBootstrapMigration, assertBootstrapConfirmation, assertMigrationSource,
269
+ restoreBootstrap, bootstrapEvidence, bootstrapExpired, watchdogAlive };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('node:crypto');
4
+ const { providerError } = require('./providerProfile');
4
5
 
5
6
  const {
6
7
  applyReasoningControl,
@@ -58,7 +59,15 @@ function appendResponsesInput(messages, input, { replayReasoning = false, toolKi
58
59
  }
59
60
  if (!Array.isArray(input)) return;
60
61
  let pendingToolCalls = [];
62
+ let pendingToolOutputContent = [];
61
63
  let pendingReasoning = '';
64
+ const flushToolOutputContent = () => {
65
+ if (!pendingToolOutputContent.length) return;
66
+ // Chat tool messages accept text only. Attach images after the complete
67
+ // tool-result group so a user message cannot split parallel tool results.
68
+ messages.push({ role: 'user', content: pendingToolOutputContent });
69
+ pendingToolOutputContent = [];
70
+ };
62
71
  const flushToolCalls = () => {
63
72
  if (!pendingToolCalls.length) return;
64
73
  const assistant = {
@@ -73,6 +82,9 @@ function appendResponsesInput(messages, input, { replayReasoning = false, toolKi
73
82
  };
74
83
  for (const item of input) {
75
84
  if (!item || typeof item !== 'object') continue;
85
+ const isToolOutput = item.type === 'function_call_output' || item.type === 'custom_tool_call_output'
86
+ || item.type === 'computer_call_output' || item.type === 'tool_search_output';
87
+ if (!isToolOutput && item.type !== 'reasoning') flushToolOutputContent();
76
88
  if (item.type === 'reasoning') {
77
89
  flushToolCalls();
78
90
  pendingReasoning = extractReasoningText(item);
@@ -83,12 +95,22 @@ function appendResponsesInput(messages, input, { replayReasoning = false, toolKi
83
95
  continue;
84
96
  }
85
97
  flushToolCalls();
86
- if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output' || item.type === 'computer_call_output' || item.type === 'tool_search_output') {
98
+ if (isToolOutput) {
99
+ const callId = normalizeText(item.call_id) || normalizeText(item.id);
100
+ const imageContent = translateImageToolOutput(item.output);
87
101
  messages.push({
88
102
  role: 'tool',
89
- tool_call_id: normalizeText(item.call_id) || normalizeText(item.id),
90
- content: normalizeToolOutput(item.output)
103
+ tool_call_id: callId,
104
+ content: imageContent
105
+ ? 'The tool output, including images, is attached after the tool results.'
106
+ : normalizeToolOutput(item.output)
91
107
  });
108
+ if (imageContent) {
109
+ pendingToolOutputContent.push(
110
+ { type: 'text', text: `Output from tool call ${callId}:` },
111
+ ...imageContent
112
+ );
113
+ }
92
114
  continue;
93
115
  }
94
116
  if (item.type === 'message' || item.role) {
@@ -105,6 +127,38 @@ function appendResponsesInput(messages, input, { replayReasoning = false, toolKi
105
127
  }
106
128
  }
107
129
  flushToolCalls();
130
+ flushToolOutputContent();
131
+ }
132
+
133
+ function translateImageToolOutput(output) {
134
+ const parts = Array.isArray(output) ? output : [output];
135
+ if (!parts.some(isImagePart)) return null;
136
+ return parts.map(part => {
137
+ if (isImagePart(part)) {
138
+ const image = translateImagePart(part);
139
+ if (!image) {
140
+ throw providerError('provider_request_invalid', 'A tool image requires an image URL or data URL for Chat Completions.');
141
+ }
142
+ return image;
143
+ }
144
+ const text = ['input_text', 'output_text', 'text'].includes(part?.type)
145
+ ? normalizeText(part.text)
146
+ : normalizeToolOutput(part);
147
+ return { type: 'text', text };
148
+ });
149
+ }
150
+
151
+ function isImagePart(part) {
152
+ return part?.type === 'input_image' || part?.type === 'image_url' || part?.type === 'computer_screenshot';
153
+ }
154
+
155
+ function translateImagePart(part) {
156
+ const url = normalizeText(part.image_url?.url || part.image_url || part.url);
157
+ if (!url) return null;
158
+ const detail = normalizeText(part.image_url?.detail || part.detail);
159
+ const image = { type: 'image_url', image_url: { url } };
160
+ if (['auto', 'low', 'high'].includes(detail)) image.image_url.detail = detail;
161
+ return image;
108
162
  }
109
163
 
110
164
  function extractReasoningText(item) {
@@ -142,10 +196,10 @@ function translateMessageContent(content) {
142
196
  if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') {
143
197
  parts.push({ type: 'text', text: normalizeText(part.text) });
144
198
  } else if (part.type === 'input_image' || part.type === 'image_url') {
145
- const url = normalizeText(part.image_url?.url || part.image_url || part.url);
146
- if (url) {
199
+ const image = translateImagePart(part);
200
+ if (image) {
147
201
  hasStructuredPart = true;
148
- parts.push({ type: 'image_url', image_url: { url } });
202
+ parts.push(image);
149
203
  }
150
204
  }
151
205
  }
@@ -2,9 +2,11 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const { requiresReasoningContentReplay } = require('./providerReasoning');
5
+ const { isContextLimitError } = require('./providerContextError');
5
6
  const MAX_STREAM_BUFFER_BYTES = 8 * 1024 * 1024;
6
7
 
7
8
  function convertChatResponse(chat = {}, context = {}) {
9
+ if (chat.error) throwUpstreamError(chat.error);
8
10
  const choice = Array.isArray(chat.choices) ? chat.choices[0] || {} : {};
9
11
  const message = choice.message || {};
10
12
  assertNoLeakedToolControlTokens(message);
@@ -131,7 +133,7 @@ function writeConvertedResponseAsSse(res, converted, context = {}, onComplete =
131
133
 
132
134
  function processChatChunk(state, res, chunk) {
133
135
  if (!chunk || typeof chunk !== 'object') return;
134
- if (chunk.error) throw new Error(normalizeText(chunk.error.message) || 'Upstream provider returned an error.');
136
+ if (chunk.error) throwUpstreamError(chunk.error);
135
137
  if (chunk.id && !state.upstreamId) {
136
138
  state.upstreamId = chunk.id;
137
139
  }
@@ -491,6 +493,13 @@ function mapChatFinishReason(value) {
491
493
  return { status: 'completed', incompleteReason: null };
492
494
  }
493
495
 
496
+ function throwUpstreamError(detail) {
497
+ throw responseError(
498
+ normalizeText(detail?.message) || normalizeText(detail) || 'Upstream provider returned an error.',
499
+ isContextLimitError(detail) ? 'context_length_exceeded' : 'provider_upstream_error'
500
+ );
501
+ }
502
+
494
503
  function responseError(message, code = 'provider_response_invalid') {
495
504
  const error = new Error(message);
496
505
  error.code = code;
@@ -13,6 +13,7 @@ const { classifyResponsesRoute } = require('./providerBridgeRoutes');
13
13
  const { providerError } = require('./providerProfile');
14
14
  const { fetchProvider, readProviderText, readProviderJson } = require('./providerHttp');
15
15
  const { sanitizeProviderMessage } = require('./providerRedaction');
16
+ const { isContextLimitError } = require('./providerContextError');
16
17
  const { logDebug } = require('./debugLog');
17
18
  const {
18
19
  createProviderStreamLifecycle,
@@ -174,7 +175,7 @@ async function handleRequest({
174
175
  }, launch);
175
176
  lifecycle.touch();
176
177
  if (!upstream.ok) {
177
- await forwardUpstreamError(upstream, res, launch.apiKey, lifecycle.touch);
178
+ await forwardUpstreamError(upstream, res, launch.apiKey, lifecycle.touch, requestBody.stream !== false);
178
179
  return;
179
180
  }
180
181
  const context = {
@@ -255,6 +256,10 @@ async function handleRequest({
255
256
  if (controller.signal.aborted) {
256
257
  throw providerError('provider_connection_timeout', 'The provider request was cancelled or timed out.');
257
258
  }
259
+ if (error?.code === 'context_length_exceeded' && !res.headersSent) {
260
+ sendContextLimitError(res, sanitizeProviderMessage(error.message, [launch.apiKey]), requestBody.stream !== false);
261
+ return;
262
+ }
258
263
  throw error;
259
264
  } finally {
260
265
  lifecycle.dispose();
@@ -382,6 +387,7 @@ function normalizeBridgeErrorCode(value) {
382
387
  'provider_reasoning_continuation_exhausted',
383
388
  'provider_stream_tool_parse_failed',
384
389
  'provider_protocol_incompatible',
390
+ 'context_length_exceeded',
385
391
  'provider_upstream_error'
386
392
  ].includes(value) ? value : 'provider_bridge_failed';
387
393
  }
@@ -392,16 +398,37 @@ function bridgeErrorMessage(code) {
392
398
  : 'The local provider bridge failed.';
393
399
  }
394
400
 
395
- async function forwardUpstreamError(upstream, res, apiKey, onActivity) {
401
+ async function forwardUpstreamError(upstream, res, apiKey, onActivity, clientStream) {
396
402
  const text = (await readProviderText(upstream, onActivity)).slice(0, MAX_ERROR_BYTES);
397
403
  let message = `Provider returned HTTP ${upstream.status}.`;
404
+ let error;
398
405
  try {
399
406
  const parsed = JSON.parse(text);
407
+ error = parsed?.error || parsed;
400
408
  message = parsed?.error?.message || parsed?.message || message;
401
409
  } catch (_error) {}
410
+ if ([400, 413, 422].includes(upstream.status) && isContextLimitError(error || text)) {
411
+ sendContextLimitError(res, sanitizeProviderMessage(error ? message : text, [apiKey]), clientStream);
412
+ return;
413
+ }
402
414
  sendJsonError(res, upstream.status, 'provider_upstream_error', sanitizeProviderMessage(message, [apiKey]));
403
415
  }
404
416
 
417
+ function sendContextLimitError(res, message, stream) {
418
+ const error = { type: 'invalid_request_error', code: 'context_length_exceeded', message };
419
+ if (!stream) {
420
+ sendJson(res, 400, { error });
421
+ return;
422
+ }
423
+ // Non-2xx JSON is a generic HTTP failure to the installed Codex client.
424
+ // Its typed context recovery consumes a Responses failure event instead.
425
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store' });
426
+ res.end(`event: response.failed\ndata: ${JSON.stringify({
427
+ type: 'response.failed', sequence_number: 0,
428
+ response: { id: `resp_${crypto.randomUUID()}`, object: 'response', status: 'failed', error }
429
+ })}\n\n`);
430
+ }
431
+
405
432
  function rememberHistory(history, responseId, messages, metadata = {}) {
406
433
  history.delete(responseId);
407
434
  history.set(responseId, { messages, ...metadata });
@@ -2,84 +2,87 @@
2
2
 
3
3
  const { truncateText } = require('./debugLog');
4
4
  const { formatOverleafWorkspaceRules } = require('./overleafWorkspaceRules');
5
+ const { buildReadProgressRules } = require('./readProgressGuard');
5
6
 
6
7
  const PROJECT_CUSTOM_INSTRUCTIONS_MAX_CHARS = 12000;
7
8
  const NATIVE_MESSAGE_PROMPT_MAX_CHARS = 1024 * 1024;
8
9
 
9
- /**
10
- * Build the full Codex turn prompt from run parameters.
11
- * @param {Object} options
12
- * @param {string} options.task - User task text.
13
- * @param {string} options.customInstructions - Project custom instructions (may be empty).
14
- * @param {Array} options.skills - Array of { id, name, content } loaded skill objects.
15
- * @param {Object|null} options.skillInvocation - { skillId, forced } if slash-selected.
16
- * @param {Array} options.attachments - Array of { name, relativePath } staged attachment refs.
17
- * @param {string} options.activePath - Currently focused file path.
18
- * @param {Array<string>} options.contextFiles - Selected context file paths.
19
- * @param {Object} options.compileContext - { enabled, logSummary } compile context.
20
- * @returns {{ systemPrompt: string, userPrompt: string }}
21
- */
10
+ /** Stable parent-thread guidance; the active mode and task belong to the turn. */
11
+ function buildCodexDeveloperInstructions(options = {}) {
12
+ const params = options.params && typeof options.params === 'object' ? options.params : options;
13
+ const locale = (options.locale ?? params.locale) === 'zh' ? 'Chinese (zh)' : 'English (en)';
14
+ const skillInvocation = options.skillInvocation || params.skillInvocation;
15
+ return [
16
+ 'You are Codex working inside the Codex Overleaf Link panel, helping an academic author with their LaTeX project in Overleaf. The user reads your answer in a narrow (~380px) side panel.',
17
+ [
18
+ 'Reply language and style:',
19
+ `- Reply in the language of the user's latest request. If unclear, use the UI language: ${locale}. Keep LaTeX source, file paths, and identifiers unchanged.`,
20
+ '- Lead with the answer or outcome. Use short paragraphs and lists; avoid wide tables and long code dumps. Do not restate the request.',
21
+ '- Complete the requested analysis or edit within this turn. Do not end with only a plan or a promise to act later.',
22
+ '- For edits, summarize what changed per file with citations. The panel already shows written files, diffs, and Undo; do not paste whole diffs.'
23
+ ].join('\n'),
24
+ ...(isSkillInstallerInvocation(skillInvocation) ? [] : [formatOverleafWorkspaceRules()]),
25
+ ['Project location citation rules:', formatProjectLocationCitationRules()].join('\n'),
26
+ ['Answer formatting:', formatMathOutputRules()].join('\n'),
27
+ [
28
+ 'Academic integrity:',
29
+ '- Never invent citations, BibTeX entries, data, results, or quotations.',
30
+ '- When a claim needs a source you cannot verify in the project, say so and suggest what kind of source is needed instead of adding one.'
31
+ ].join('\n'),
32
+ [
33
+ 'Editing discipline for writable turns:',
34
+ '- Change only what the request needs. Keep unrelated text, whitespace, and formatting untouched; small diffs keep Overleaf tracked changes and Undo reviewable.',
35
+ '- Keep \\label, \\ref, and \\cite keys and file names stable unless asked to change them.',
36
+ '- Keep the document compilable, with balanced braces and environments.'
37
+ ].join('\n'),
38
+ [
39
+ 'Modes:',
40
+ '- Each turn states its active mode. These modes govern project work; explicit skill-install turns follow their separate installation scope.',
41
+ '- Ask is read-only: inspect and explain. When suggesting edits, show short LaTeX snippets with citations and mention that Auto mode can apply them.',
42
+ '- Auto may edit the local workspace. The browser bridge handles authorization, deletion approval, and syncing to Overleaf.'
43
+ ].join('\n'),
44
+ buildReadProgressRules()
45
+ ].join('\n\n');
46
+ }
47
+
48
+ /** Build dynamic per-turn context and the current user request. */
22
49
  function buildCodexTurnPrompt(options = {}) {
23
50
  const context = normalizePromptOptions(options);
24
-
25
- const systemPrompt = [
26
- 'Same Codex Overleaf session context:',
27
- `Session id: ${context.session.id || 'none'}`,
28
- '',
29
- 'Recent turns in this UI session:',
30
- formatSessionHistory(context.session.history),
31
- '',
32
- 'Current Overleaf workspace:',
33
- `- Project: ${context.projectKey || context.projectId || 'unknown'}`,
34
- ...formatWorkspaceSnapshotContext(context.unreadFiles),
35
- ...(isSkillInstallerInvocation(context.skillInvocation) ? [] : ['', formatOverleafWorkspaceRules()]),
36
- '',
37
- 'Project location citation rules:',
38
- formatProjectLocationCitationRules(),
39
- formatMathOutputRules(),
40
- '',
41
- 'Focused project file inventory:',
42
- formatFocusedProjectFileInventory(context.focusFiles),
43
- '',
44
- 'Focus files:',
45
- formatFocusFiles(context.focusFiles),
46
- '',
47
- 'Compilation context (@compile-log):',
48
- formatCompileLogContext(context),
49
- '',
50
- 'Project custom instructions:',
51
- formatCustomInstructionsContext(context.customInstructions),
52
- '',
53
- 'Project local skills:',
54
- formatProjectLocalSkillsContext(context),
55
- '',
56
- 'Codex skill loading:',
57
- formatCodexSkillLoadingContext(context),
58
- '',
59
- 'Selected Codex skill:',
60
- formatSkillInvocationContext(context),
61
- '',
62
- 'Attachments for this turn:',
63
- formatTurnAttachmentsContext(context.attachments),
64
- '',
65
- 'Mode for this turn:',
66
- `- ${context.mode}`,
67
- '- ask: inspect and explain only; do not edit files.',
68
- '- auto: edit the local workspace directly when the request calls for changes. The browser bridge handles authorization, deletion approval, and syncing back to Overleaf.',
69
- '',
70
- 'Write expectation for this turn:',
71
- formatWriteExpectation({
72
- mode: context.mode,
73
- task: context.userTask,
74
- skillInvocation: context.skillInvocation
75
- })
76
- ].join('\n');
77
-
78
- const userPrompt = [
79
- 'Current user request:',
80
- context.userTask || '(empty request)'
81
- ].join('\n');
82
-
51
+ const sections = [
52
+ `Mode for this turn: ${context.mode}`,
53
+ ['Write expectation for this turn:', formatWriteExpectation(context)].join('\n')
54
+ ];
55
+ if (!isSkillInstallerInvocation(context.skillInvocation)) {
56
+ sections.push([
57
+ 'Current Overleaf workspace:',
58
+ ...(context.projectKey || context.projectId ? [`- Project: ${context.projectKey || context.projectId}`] : []),
59
+ ...formatWorkspaceSnapshotContext(context.unreadFiles)
60
+ ].join('\n'));
61
+ }
62
+ if (!context.threadId && Array.isArray(context.session.history) && context.session.history.length) {
63
+ sections.push(['Recent turns in this UI session:', formatSessionHistory(context.session.history)].join('\n'));
64
+ }
65
+ if (context.focusFiles.length) {
66
+ sections.push(['Focus files:', formatFocusFiles(context.focusFiles)].join('\n'));
67
+ }
68
+ if (String(context.compileLog || '').trim()) {
69
+ sections.push(['Compilation context (@compile-log):', formatCompileLogContext(context)].join('\n'));
70
+ }
71
+ if (String(context.customInstructions || '').trim()) {
72
+ sections.push(['Project custom instructions:', formatCustomInstructionsContext(context.customInstructions)].join('\n'));
73
+ }
74
+ if (context.selectedSkillIds.length && (context.projectLocalSkills.skills?.length || context.projectLocalSkills.missing?.length)) {
75
+ sections.push(['Project local skills:', formatProjectLocalSkillsContext(context)].join('\n'));
76
+ }
77
+ if (normalizeSkillInvocation(context.skillInvocation) || normalizeSkillInvocation(context.codexSkillInvocationContext?.invocation)
78
+ || context.codexSkillInvocationContext?.ignored?.length || context.codexSkillInvocationContext?.missing?.length) {
79
+ sections.push(['Selected Codex skill:', formatSkillInvocationContext(context)].join('\n'));
80
+ }
81
+ if (context.attachments.length) {
82
+ sections.push(['Attachments for this turn:', formatTurnAttachmentsContext(context.attachments)].join('\n'));
83
+ }
84
+ const systemPrompt = sections.join('\n\n');
85
+ const userPrompt = ['Current user request:', context.userTask || '(empty request)'].join('\n');
83
86
  return { systemPrompt, userPrompt };
84
87
  }
85
88
 
@@ -87,10 +90,12 @@ function buildCodexTurnPrompt(options = {}) {
87
90
  * Validate prompt size against native messaging limits.
88
91
  * @param {string} systemPrompt
89
92
  * @param {string} userPrompt
93
+ * @param {string} developerInstructions
90
94
  * @returns {{ ok: boolean, totalChars: number, exceedsLimit: boolean }}
91
95
  */
92
- function validatePromptSize(systemPrompt, userPrompt) {
93
- const totalChars = String(systemPrompt || '').length + String(userPrompt || '').length;
96
+ function validatePromptSize(systemPrompt, userPrompt, developerInstructions = '') {
97
+ const totalChars = String(systemPrompt || '').length + String(userPrompt || '').length
98
+ + String(developerInstructions || '').length;
94
99
  const exceedsLimit = totalChars > NATIVE_MESSAGE_PROMPT_MAX_CHARS;
95
100
  return {
96
101
  ok: !exceedsLimit,
@@ -127,6 +132,7 @@ function normalizePromptOptions(options = {}) {
127
132
  return {
128
133
  params,
129
134
  session,
135
+ threadId: String(options.threadId ?? params.threadId ?? '').trim(),
130
136
  projectKey: mirror.projectKey || options.projectKey || params.projectId || params.project?.id || params.project?.projectId,
131
137
  projectId: options.projectId || params.projectId || params.project?.id || params.project?.projectId,
132
138
  workspacePath: mirror.workspacePath || options.workspacePath || '',
@@ -156,8 +162,7 @@ function normalizePromptOptions(options = {}) {
156
162
 
157
163
  function formatWorkspaceSnapshotContext(unreadFiles) {
158
164
  if (!unreadFiles.length) return [
159
- '- The local workspace was synced from Overleaf immediately before this turn.',
160
- '- If the recent session history conflicts with the files in the workspace, trust the files.'
165
+ '- The local workspace was synced from Overleaf before this turn; trust the files over conflicting session history.'
161
166
  ];
162
167
  return [
163
168
  '- The readable project files were synced from Overleaf before this turn.',
@@ -196,7 +201,7 @@ function formatTurnAttachmentsContext(attachments = []) {
196
201
  ].join('\n');
197
202
  }
198
203
 
199
- function formatWriteExpectation({ mode = 'auto', task = '', skillInvocation = null } = {}) {
204
+ function formatWriteExpectation({ mode = 'auto', skillInvocation = null } = {}) {
200
205
  if (isSkillInstallerInvocation(skillInvocation)) {
201
206
  return [
202
207
  '- This is a skill-install turn.',
@@ -208,29 +213,7 @@ function formatWriteExpectation({ mode = 'auto', task = '', skillInvocation = nu
208
213
  if (mode === 'ask') {
209
214
  return '- This is read-only. Inspect and explain; do not edit files.';
210
215
  }
211
- if (requestImpliesFileChanges(task)) {
212
- const lines = [
213
- '- The request asks for file changes. You must edit the local workspace when you find concrete fixes.',
214
- '- Do not stop at a suggestion list or say you will not modify files unless no safe concrete edit exists.',
215
- '- If you intentionally leave files unchanged, explain the specific blocker in the final answer.'
216
- ];
217
- if (requestTargetsAbstract(task)) {
218
- lines.push('- The request targets the abstract/summary. Locate the LaTeX abstract environment or summary paragraph and edit that local project file directly.');
219
- }
220
- return lines.join('\n');
221
- }
222
- return [
223
- '- This mode can write. If the request asks for corrections, revisions, fixes, polishing, updates, or implementation, edit the local workspace directly.',
224
- '- If the request is purely an inspection question, report findings without inventing unnecessary edits.'
225
- ].join('\n');
226
- }
227
-
228
- function requestImpliesFileChanges(task = '') {
229
- return /修正|修复|修改|改[一-龥]*|完善|补全|补充|润色|重写|改写|整理|调整|应用|写入|fix|correct|repair|revise|edit|update|rewrite|polish|improve|apply/i.test(String(task || ''));
230
- }
231
-
232
- function requestTargetsAbstract(task = '') {
233
- return /摘要|abstract|summary/i.test(String(task || ''));
216
+ return '- Decide from the request whether it asks for changes. If it asks you to fix, revise, polish, rewrite, apply or otherwise change the project, edit the workspace files directly instead of only suggesting; if it asks a question, answer it without editing. If you intentionally leave files unchanged for a change request, explain the blocker.';
234
217
  }
235
218
 
236
219
  function normalizeFocusFiles(value) {
@@ -271,8 +254,8 @@ function normalizeProjectPath(value) {
271
254
 
272
255
  function formatProjectLocationCitationRules() {
273
256
  return [
274
- '- In any user-visible output, cite project locations using only Overleaf project-relative paths from the project file inventory below.',
275
- '- Use path:LINE[:COLUMN] (for example, path/to/file.tex:12:3).',
257
+ '- In any user-visible output, cite project locations using only Overleaf project-relative paths of files in this workspace.',
258
+ '- Use path:LINE[:COLUMN] for a position and path:START-END for a span (for example, path/to/file.tex:12:3 or main.tex:12-20).',
276
259
  '- Do not wrap project location citations in backticks or inline code; write main.tex:28 as normal text so the browser can turn it into a jump button.',
277
260
  '- Do not cite local absolute paths, temporary workspace paths, file:// URLs, or markdown links to local files.',
278
261
  '- If a line number is uncertain, cite only the project-relative file path.'
@@ -289,14 +272,6 @@ function formatMathOutputRules() {
289
272
  ].join('\n');
290
273
  }
291
274
 
292
- function formatFocusedProjectFileInventory(files) {
293
- const focusFiles = normalizeFocusFiles(files);
294
- if (!focusFiles.length) {
295
- return '- none selected.';
296
- }
297
- return focusFiles.map(filePath => `- ${filePath}`).join('\n');
298
- }
299
-
300
275
  function formatFocusFiles(files) {
301
276
  if (!files.length) {
302
277
  return '- none; use the whole project when needed.';
@@ -396,13 +371,6 @@ function formatProjectLocalSkillsContext(context = {}) {
396
371
  return sections.length ? sections.join('\n\n') : '- none loaded.';
397
372
  }
398
373
 
399
- function formatCodexSkillLoadingContext(context = {}) {
400
- return [
401
- `- Codex local skills: ${context.loadCodexLocalSkills === false ? 'disabled' : 'enabled'}`,
402
- `- Codex Overleaf skills: ${context.loadCodexOverleafSkills === false ? 'disabled' : 'enabled'}`
403
- ].join('\n');
404
- }
405
-
406
374
  function formatSkillInvocationContext(context = {}) {
407
375
  const invocation = normalizeSkillInvocation(context.skillInvocation);
408
376
  const codexSkillInvocationContext = context.codexSkillInvocationContext || {};
@@ -514,4 +482,4 @@ function formatSessionHistory(history) {
514
482
  }).join('\n');
515
483
  }
516
484
 
517
- module.exports = { buildCodexTurnPrompt, validatePromptSize };
485
+ module.exports = { buildCodexDeveloperInstructions, buildCodexTurnPrompt, validatePromptSize };