dsh-lcx-codex 0.3.0
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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/cordis.patch.yml +18 -0
- package/lib/checkpoint-store-v3.js +459 -0
- package/lib/client.js +248 -0
- package/lib/compact-v2.js +305 -0
- package/lib/compact.js +653 -0
- package/lib/dsh-pi-responses.js +339 -0
- package/lib/index.js +1757 -0
- package/lib/private-file.js +18 -0
- package/lib/session-lease.js +70 -0
- package/lib/transport.js +396 -0
- package/lib/web-run-output.js +157 -0
- package/lib/web-search-alpha.js +451 -0
- package/lib/web-search-capability.js +78 -0
- package/lib/web-search-hosted.js +383 -0
- package/lib/web-search-ref-store.js +70 -0
- package/lib/web-search-store.js +181 -0
- package/package.json +99 -0
- package/scripts/probe-alpha.mjs +91 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export function ensurePrivateFileAcl(target) {
|
|
4
|
+
if (process.platform !== 'win32') return true
|
|
5
|
+
try {
|
|
6
|
+
const identity = execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], {
|
|
7
|
+
encoding: 'utf8', timeout: 3000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
8
|
+
}).match(/S-1-[0-9-]+/iu)?.[0]
|
|
9
|
+
if (!identity) return false
|
|
10
|
+
const sids = [identity, 'S-1-5-18', 'S-1-5-32-544'].map((sid) => `*${sid}:F`)
|
|
11
|
+
execFileSync('icacls.exe', [target, '/inheritance:r', '/grant:r', ...sids], {
|
|
12
|
+
encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore',
|
|
13
|
+
})
|
|
14
|
+
return true
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const STALE_SESSION_CODE = 'LCX_SESSION_GENERATION_STALE'
|
|
2
|
+
|
|
3
|
+
function sessionIdOf(session) {
|
|
4
|
+
const value = session?.id
|
|
5
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function staleSessionError(sessionId, phase) {
|
|
9
|
+
const error = new Error(`LCX session generation became stale during ${phase}: ${sessionId}`)
|
|
10
|
+
error.code = STALE_SESSION_CODE
|
|
11
|
+
return error
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Track the public DSH session lifecycle without depending on private agent-loop APIs.
|
|
16
|
+
* A lease is intentionally fail-closed when a session service exists but the id is
|
|
17
|
+
* unknown; callers without a session id (or without the optional service) retain
|
|
18
|
+
* the pre-lifecycle behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function createSessionGenerationTracker(ctx) {
|
|
21
|
+
const sessions = ctx?.get?.('sessions') ?? ctx?.sessions
|
|
22
|
+
const enabled = Boolean(sessions && typeof ctx?.on === 'function')
|
|
23
|
+
const states = new Map()
|
|
24
|
+
const disposers = []
|
|
25
|
+
|
|
26
|
+
const created = (session) => {
|
|
27
|
+
const id = sessionIdOf(session)
|
|
28
|
+
if (!id) return
|
|
29
|
+
const previous = states.get(id)
|
|
30
|
+
states.set(id, { generation: (previous?.generation ?? 0) + 1, active: true })
|
|
31
|
+
}
|
|
32
|
+
const disposed = (session) => {
|
|
33
|
+
const id = sessionIdOf(session)
|
|
34
|
+
if (!id) return
|
|
35
|
+
const previous = states.get(id)
|
|
36
|
+
states.set(id, { generation: (previous?.generation ?? 0) + 1, active: false })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (enabled) {
|
|
40
|
+
for (const session of sessions.list?.() ?? []) created(session)
|
|
41
|
+
for (const [event, handler] of [['session/created', created], ['session/disposed', disposed]]) {
|
|
42
|
+
const dispose = ctx.on(event, handler)
|
|
43
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
capture(sessionId) {
|
|
49
|
+
const id = typeof sessionId === 'string' ? sessionId : ''
|
|
50
|
+
if (!enabled || !id) return { assert() {} }
|
|
51
|
+
const captured = states.get(id)
|
|
52
|
+
const generation = captured?.generation ?? 0
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
generation,
|
|
56
|
+
assert(phase = 'commit') {
|
|
57
|
+
const current = states.get(id)
|
|
58
|
+
if (!current || !current.active || current.generation !== generation) {
|
|
59
|
+
throw staleSessionError(id, phase)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
while (disposers.length > 0) disposers.pop()?.()
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { STALE_SESSION_CODE }
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
2
|
+
|
|
3
|
+
const MAX_RETRY_DELAY_MS = 30_000
|
|
4
|
+
const combinedSignalStates = new WeakMap()
|
|
5
|
+
|
|
6
|
+
export class LcxHttpError extends Error {
|
|
7
|
+
constructor(message, { code = 'LCX_HTTP_ERROR', status, retryable = false, cause } = {}) {
|
|
8
|
+
super(message, cause === undefined ? undefined : { cause })
|
|
9
|
+
this.name = 'LcxHttpError'
|
|
10
|
+
this.code = code
|
|
11
|
+
this.status = status
|
|
12
|
+
this.retryable = retryable
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function abortIfNeeded(signal) {
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
throw signal.reason instanceof Error ? signal.reason : new Error('request aborted')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function responseDetail(body) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(body)
|
|
25
|
+
if (typeof parsed?.error === 'string') return parsed.error
|
|
26
|
+
if (typeof parsed?.error?.message === 'string') return parsed.error.message
|
|
27
|
+
if (typeof parsed?.message === 'string') return parsed.message
|
|
28
|
+
} catch {
|
|
29
|
+
// The status and a bounded body preview are enough when the gateway did not return JSON.
|
|
30
|
+
}
|
|
31
|
+
return String(body ?? '').replace(/\s+/gu, ' ').trim().slice(0, 500)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function retryableStatus(status) {
|
|
35
|
+
return status === 429 || (status >= 500 && status <= 599)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function combinedSignal(signal, timeoutMs) {
|
|
39
|
+
const controller = new AbortController()
|
|
40
|
+
let timer
|
|
41
|
+
const abort = (reason) => {
|
|
42
|
+
if (controller.signal.aborted) return
|
|
43
|
+
if (timer !== undefined) globalThis.clearTimeout(timer)
|
|
44
|
+
combinedSignalStates.delete(controller.signal)
|
|
45
|
+
signal?.removeEventListener('abort', onCallerAbort)
|
|
46
|
+
controller.abort(reason)
|
|
47
|
+
}
|
|
48
|
+
const onCallerAbort = () => abort(signal.reason)
|
|
49
|
+
if (Number.isFinite(timeoutMs)) {
|
|
50
|
+
timer = globalThis.setTimeout(() => {
|
|
51
|
+
abort(new DOMException(`The operation was aborted due to timeout`, 'TimeoutError'))
|
|
52
|
+
}, Math.max(0, timeoutMs))
|
|
53
|
+
timer.unref?.()
|
|
54
|
+
}
|
|
55
|
+
combinedSignalStates.set(controller.signal, { timer, callerSignal: signal, onCallerAbort })
|
|
56
|
+
if (signal?.aborted) abort(signal.reason)
|
|
57
|
+
else signal?.addEventListener('abort', onCallerAbort, { once: true })
|
|
58
|
+
return controller.signal
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function holdSignal(signal) {
|
|
62
|
+
combinedSignalStates.get(signal)?.timer?.ref?.()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function releaseSignal(signal) {
|
|
66
|
+
const state = combinedSignalStates.get(signal)
|
|
67
|
+
if (!state) return
|
|
68
|
+
if (state.timer !== undefined) globalThis.clearTimeout(state.timer)
|
|
69
|
+
state.callerSignal?.removeEventListener('abort', state.onCallerAbort)
|
|
70
|
+
combinedSignalStates.delete(signal)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function retryAfterMilliseconds(response) {
|
|
74
|
+
const value = response?.headers?.get?.('retry-after')
|
|
75
|
+
if (!value) return undefined
|
|
76
|
+
const seconds = Number(value)
|
|
77
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(MAX_RETRY_DELAY_MS, Math.ceil(seconds * 1000))
|
|
78
|
+
const date = Date.parse(value)
|
|
79
|
+
return Number.isFinite(date) ? Math.min(MAX_RETRY_DELAY_MS, Math.max(0, date - Date.now())) : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function timeoutError(timeoutMs, cause) {
|
|
83
|
+
return new LcxHttpError(`LCX request timed out after ${timeoutMs} ms`, { code: 'LCX_TIMEOUT', retryable: true, cause })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function abortRequestIfNeeded(requestSignal, callerSignal, timeoutMs) {
|
|
87
|
+
abortIfNeeded(callerSignal)
|
|
88
|
+
if (!requestSignal?.aborted) return
|
|
89
|
+
if (requestSignal.reason?.name === 'TimeoutError') throw timeoutError(timeoutMs, requestSignal.reason)
|
|
90
|
+
throw requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function retryWaitMilliseconds(error, baseDelayMs, attempt) {
|
|
94
|
+
const retryAfter = Number(error?.retryAfterMs)
|
|
95
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0) return Math.min(MAX_RETRY_DELAY_MS, retryAfter)
|
|
96
|
+
const exponential = baseDelayMs * (2 ** (attempt - 1))
|
|
97
|
+
return Number.isFinite(exponential) && exponential >= 0 ? Math.min(MAX_RETRY_DELAY_MS, exponential) : MAX_RETRY_DELAY_MS
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readLimitedText(response, maxResponseBytes, signal, label) {
|
|
101
|
+
if (!response?.body) return ''
|
|
102
|
+
const reader = response.body.getReader()
|
|
103
|
+
const decoder = new TextDecoder()
|
|
104
|
+
const parts = []
|
|
105
|
+
let bytes = 0
|
|
106
|
+
const cancel = () => { reader.cancel(signal?.reason).catch(() => undefined) }
|
|
107
|
+
try {
|
|
108
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
109
|
+
while (true) {
|
|
110
|
+
abortIfNeeded(signal)
|
|
111
|
+
const result = await reader.read()
|
|
112
|
+
abortIfNeeded(signal)
|
|
113
|
+
if (result.done) break
|
|
114
|
+
bytes += result.value.byteLength
|
|
115
|
+
if (bytes > maxResponseBytes) {
|
|
116
|
+
throw new LcxHttpError(`LCX ${label} response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
117
|
+
}
|
|
118
|
+
parts.push(decoder.decode(result.value, { stream: true }))
|
|
119
|
+
}
|
|
120
|
+
parts.push(decoder.decode())
|
|
121
|
+
return parts.join('')
|
|
122
|
+
} finally {
|
|
123
|
+
signal?.removeEventListener('abort', cancel)
|
|
124
|
+
await reader.cancel().catch(() => undefined)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeReadError(error, requestSignal, timeoutMs) {
|
|
129
|
+
if (requestSignal?.aborted && requestSignal.reason?.name === 'TimeoutError') return timeoutError(timeoutMs, error)
|
|
130
|
+
return error
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function abortableResponse(response, requestSignal, callerSignal, timeoutMs, ownsRequestSignal = false) {
|
|
134
|
+
if (!response?.body || !requestSignal) return response
|
|
135
|
+
const source = response.body
|
|
136
|
+
const reader = source.getReader()
|
|
137
|
+
let abortError
|
|
138
|
+
let cleanedUp = false
|
|
139
|
+
let pendingReadReject
|
|
140
|
+
let onAbort
|
|
141
|
+
const cleanup = () => {
|
|
142
|
+
if (cleanedUp) return
|
|
143
|
+
cleanedUp = true
|
|
144
|
+
requestSignal.removeEventListener('abort', onAbort)
|
|
145
|
+
if (ownsRequestSignal) releaseSignal(requestSignal)
|
|
146
|
+
}
|
|
147
|
+
const abort = () => {
|
|
148
|
+
if (abortError) return
|
|
149
|
+
abortError = callerSignal?.aborted
|
|
150
|
+
? (callerSignal.reason instanceof Error ? callerSignal.reason : new Error('request aborted'))
|
|
151
|
+
: requestSignal.reason?.name === 'TimeoutError'
|
|
152
|
+
? timeoutError(timeoutMs, requestSignal.reason)
|
|
153
|
+
: (requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted'))
|
|
154
|
+
cleanup()
|
|
155
|
+
pendingReadReject?.(abortError)
|
|
156
|
+
reader.cancel(abortError).catch(() => undefined)
|
|
157
|
+
}
|
|
158
|
+
onAbort = abort
|
|
159
|
+
const stream = new ReadableStream({
|
|
160
|
+
start(controller) {
|
|
161
|
+
if (requestSignal.aborted) {
|
|
162
|
+
abort()
|
|
163
|
+
controller.error(abortError)
|
|
164
|
+
} else {
|
|
165
|
+
requestSignal.addEventListener('abort', onAbort, { once: true })
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async pull(controller) {
|
|
169
|
+
if (abortError) return
|
|
170
|
+
try {
|
|
171
|
+
const result = await new Promise((resolve, reject) => {
|
|
172
|
+
pendingReadReject = reject
|
|
173
|
+
reader.read().then(resolve, reject)
|
|
174
|
+
})
|
|
175
|
+
pendingReadReject = undefined
|
|
176
|
+
if (abortError) return
|
|
177
|
+
if (result.done) {
|
|
178
|
+
cleanup()
|
|
179
|
+
controller.close()
|
|
180
|
+
} else {
|
|
181
|
+
controller.enqueue(result.value)
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
pendingReadReject = undefined
|
|
185
|
+
cleanup()
|
|
186
|
+
controller.error(abortError ?? error)
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
cancel(reason) {
|
|
190
|
+
cleanup()
|
|
191
|
+
reader.cancel(reason).catch(() => undefined)
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
return new Response(stream, {
|
|
195
|
+
status: response.status,
|
|
196
|
+
statusText: response.statusText,
|
|
197
|
+
headers: response.headers,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function fetchJson(url, body, headers, signal, timeoutMs, options = {}) {
|
|
202
|
+
abortIfNeeded(signal)
|
|
203
|
+
const maxResponseBytes = options.maxResponseBytes ?? 4 * 1024 * 1024
|
|
204
|
+
const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
|
|
205
|
+
const ownsRequestSignal = !options.requestSignal
|
|
206
|
+
if (ownsRequestSignal) holdSignal(requestSignal)
|
|
207
|
+
try {
|
|
208
|
+
abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
209
|
+
let serializedBody
|
|
210
|
+
try {
|
|
211
|
+
serializedBody = JSON.stringify(body)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
|
|
214
|
+
}
|
|
215
|
+
let response
|
|
216
|
+
try {
|
|
217
|
+
response = await fetch(url, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
redirect: 'error',
|
|
220
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', ...headers },
|
|
221
|
+
body: serializedBody,
|
|
222
|
+
signal: requestSignal,
|
|
223
|
+
})
|
|
224
|
+
} catch (error) {
|
|
225
|
+
abortIfNeeded(signal)
|
|
226
|
+
if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
227
|
+
if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const contentLength = Number(response.headers.get('content-length') ?? 0)
|
|
232
|
+
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
|
233
|
+
throw new LcxHttpError(`LCX response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let text
|
|
237
|
+
try {
|
|
238
|
+
text = await readLimitedText(response, maxResponseBytes, requestSignal, 'JSON')
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw normalizeReadError(error, requestSignal, timeoutMs)
|
|
241
|
+
}
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
const detail = responseDetail(text)
|
|
244
|
+
const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
|
|
245
|
+
code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
|
|
246
|
+
status: response.status,
|
|
247
|
+
retryable: retryableStatus(response.status),
|
|
248
|
+
})
|
|
249
|
+
error.retryAfterMs = retryAfterMilliseconds(response)
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
throw new LcxHttpError(`LCX returned a non-JSON response: ${String(error)}`, { code: 'LCX_INVALID_JSON', status: response.status, cause: error })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
if (ownsRequestSignal) releaseSignal(requestSignal)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function fetchSse(url, body, headers, signal, timeoutMs, options = {}) {
|
|
264
|
+
abortIfNeeded(signal)
|
|
265
|
+
const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024
|
|
266
|
+
const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
|
|
267
|
+
const ownsRequestSignal = !options.requestSignal
|
|
268
|
+
if (ownsRequestSignal) holdSignal(requestSignal)
|
|
269
|
+
let handedOff = false
|
|
270
|
+
try {
|
|
271
|
+
abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
272
|
+
let serializedBody
|
|
273
|
+
try {
|
|
274
|
+
serializedBody = JSON.stringify(body)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
|
|
277
|
+
}
|
|
278
|
+
let response
|
|
279
|
+
try {
|
|
280
|
+
response = await fetch(url, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
redirect: 'error',
|
|
283
|
+
headers: { accept: 'text/event-stream', 'content-type': 'application/json', ...headers },
|
|
284
|
+
body: serializedBody,
|
|
285
|
+
signal: requestSignal,
|
|
286
|
+
})
|
|
287
|
+
} catch (error) {
|
|
288
|
+
abortIfNeeded(signal)
|
|
289
|
+
if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
290
|
+
if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
|
|
291
|
+
throw error
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const contentLength = Number(response.headers.get('content-length') ?? 0)
|
|
295
|
+
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
|
296
|
+
throw new LcxHttpError(`LCX SSE response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
297
|
+
}
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
let text
|
|
300
|
+
try {
|
|
301
|
+
text = await readLimitedText(response, maxResponseBytes, requestSignal, 'SSE')
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw normalizeReadError(error, requestSignal, timeoutMs)
|
|
304
|
+
}
|
|
305
|
+
const detail = responseDetail(text)
|
|
306
|
+
const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
|
|
307
|
+
code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
|
|
308
|
+
status: response.status,
|
|
309
|
+
retryable: retryableStatus(response.status),
|
|
310
|
+
})
|
|
311
|
+
error.retryAfterMs = retryAfterMilliseconds(response)
|
|
312
|
+
throw error
|
|
313
|
+
}
|
|
314
|
+
if (!response.body) {
|
|
315
|
+
throw new LcxHttpError('LCX SSE response has no body', { code: 'LCX_INVALID_SSE', status: response.status })
|
|
316
|
+
}
|
|
317
|
+
const wrapped = abortableResponse(response, requestSignal, signal, timeoutMs, ownsRequestSignal)
|
|
318
|
+
handedOff = ownsRequestSignal
|
|
319
|
+
return wrapped
|
|
320
|
+
} finally {
|
|
321
|
+
if (ownsRequestSignal && !handedOff) releaseSignal(requestSignal)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function waitForRetry(waitMs, operationSignal, callerSignal, timeoutMs) {
|
|
326
|
+
abortRequestIfNeeded(operationSignal, callerSignal, timeoutMs)
|
|
327
|
+
try {
|
|
328
|
+
await delay(waitMs, undefined, { signal: operationSignal })
|
|
329
|
+
} catch (error) {
|
|
330
|
+
abortRequestIfNeeded(operationSignal, callerSignal, timeoutMs)
|
|
331
|
+
throw error
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function transientNetworkError(error) {
|
|
336
|
+
if (!error || error.name === 'AbortError' || error.name === 'TimeoutError') return false
|
|
337
|
+
if (error instanceof LcxHttpError) return error.code === 'LCX_HTTP_RETRYABLE' || error.code === 'LCX_TIMEOUT'
|
|
338
|
+
return error.retryable === true || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(error.code)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function fetchSseWithRetry(url, body, headers, signal, timeoutMs, options = {}) {
|
|
342
|
+
const maxAttempts = Math.max(1, Math.min(options.maxAttempts ?? 3, 6))
|
|
343
|
+
const baseDelayMs = Math.max(0, options.baseDelayMs ?? 250)
|
|
344
|
+
const consume = options.consume
|
|
345
|
+
if (typeof consume !== 'function') throw new TypeError('fetchSseWithRetry requires a consume callback')
|
|
346
|
+
const operationSignal = combinedSignal(signal, timeoutMs)
|
|
347
|
+
holdSignal(operationSignal)
|
|
348
|
+
let attempt = 0
|
|
349
|
+
try {
|
|
350
|
+
while (attempt < maxAttempts) {
|
|
351
|
+
attempt += 1
|
|
352
|
+
try {
|
|
353
|
+
abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
354
|
+
const response = await fetchSse(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
|
|
355
|
+
try {
|
|
356
|
+
return await consume(response, { ...options, requestSignal: operationSignal })
|
|
357
|
+
} finally {
|
|
358
|
+
if (response.body) await response.body.cancel().catch(() => undefined)
|
|
359
|
+
}
|
|
360
|
+
} catch (error) {
|
|
361
|
+
abortIfNeeded(signal)
|
|
362
|
+
if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
363
|
+
if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
|
|
364
|
+
await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
throw new LcxHttpError('LCX SSE retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
|
|
368
|
+
} finally {
|
|
369
|
+
releaseSignal(operationSignal)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function fetchJsonWithRetry(url, body, headers, signal, timeoutMs, options = {}) {
|
|
374
|
+
const maxAttempts = Math.max(1, Math.min(options.maxAttempts ?? 3, 6))
|
|
375
|
+
const baseDelayMs = Math.max(0, options.baseDelayMs ?? 250)
|
|
376
|
+
const operationSignal = combinedSignal(signal, timeoutMs)
|
|
377
|
+
holdSignal(operationSignal)
|
|
378
|
+
let attempt = 0
|
|
379
|
+
try {
|
|
380
|
+
while (attempt < maxAttempts) {
|
|
381
|
+
attempt += 1
|
|
382
|
+
try {
|
|
383
|
+
abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
384
|
+
return await fetchJson(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
abortIfNeeded(signal)
|
|
387
|
+
if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
388
|
+
if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
|
|
389
|
+
await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
throw new LcxHttpError('LCX retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
|
|
393
|
+
} finally {
|
|
394
|
+
releaseSignal(operationSignal)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const RESULT_SEPARATOR_PATTERN = /(?:\r?\n)?-{80}(?:\r?\n)?/gu
|
|
2
|
+
const CITATION_PATTERN = /cite([^]+)/gu
|
|
3
|
+
const PAGE_LINE_PATTERN = /^L(\d+)(?:@P(\d+)(?:-(\d+))?)?:\s?(.*)$/u
|
|
4
|
+
const EMBEDDED_PAGE_LINE_PATTERN = / (?=L\d+(?:@P\d+(?:-\d+)?)?:)/gu
|
|
5
|
+
const TITLE_URL_PATTERN = /^(.*?)\s*\((https?:\/\/[^)]+)\)\s*$/u
|
|
6
|
+
|
|
7
|
+
function pushUnique(target, value) {
|
|
8
|
+
if (!target.includes(value)) target.push(value)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pushLink(target, payload) {
|
|
12
|
+
const [idValue, labelValue, domainValue] = payload.split('†')
|
|
13
|
+
if (!/^\d+$/u.test(idValue ?? '') || !labelValue) return false
|
|
14
|
+
const id = Number(idValue)
|
|
15
|
+
if (!Number.isSafeInteger(id)) return false
|
|
16
|
+
const label = labelValue.trim().slice(0, 500)
|
|
17
|
+
const domain = domainValue?.trim().slice(0, 253)
|
|
18
|
+
if (!label) return false
|
|
19
|
+
const existing = target.find((link) => link.id === id)
|
|
20
|
+
if (!existing) target.push({ id, label, ...(domain ? { domain } : {}) })
|
|
21
|
+
else if (!existing.domain && domain) existing.domain = domain
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanCitations(value, references, links) {
|
|
26
|
+
return String(value ?? '').replace(CITATION_PATTERN, (_match, payload) => {
|
|
27
|
+
if (/^turn[\w-]+$/u.test(payload)) {
|
|
28
|
+
pushUnique(references, payload)
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
if (pushLink(links, payload)) return payload.split('†')[1]
|
|
32
|
+
const separator = payload.indexOf('†')
|
|
33
|
+
return separator < 0 ? payload : payload.slice(separator + 1)
|
|
34
|
+
}).trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function metadataParts(value) {
|
|
38
|
+
return value
|
|
39
|
+
.replace(/^\[wordlim:\s*(\d+)\]\s*/u, '$1-word excerpt; ')
|
|
40
|
+
.split(/;\s*/u)
|
|
41
|
+
.map((part) => part.trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((part) => {
|
|
44
|
+
const contentType = part.match(/^Content type:\s*(.+)$/u)
|
|
45
|
+
if (contentType) return contentType[1] === 'text/html' ? 'HTML' : contentType[1] === 'application/pdf' ? 'PDF' : contentType[1]
|
|
46
|
+
const totalLines = part.match(/^Total lines:\s*(\d+)$/u)
|
|
47
|
+
if (totalLines) return `${totalLines[1]} lines`
|
|
48
|
+
const pages = part.match(/^Number of pages:\s*(\d+)$/u)
|
|
49
|
+
if (pages) return `${pages[1]} pages`
|
|
50
|
+
return part
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isMetadata(value) {
|
|
55
|
+
return value.startsWith('[wordlim:') || /^(?:Published|Crawled|Content type|Source|Total lines|Number of pages):/u.test(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseLine(value, references, links) {
|
|
59
|
+
const clean = cleanCitations(value, references, links)
|
|
60
|
+
const pageLine = clean.match(PAGE_LINE_PATTERN)
|
|
61
|
+
if (pageLine) {
|
|
62
|
+
const text = pageLine[4] ?? ''
|
|
63
|
+
const heading = text.match(/^(#{1,6})\s+(.+)$/u)
|
|
64
|
+
return {
|
|
65
|
+
line: Number(pageLine[1]),
|
|
66
|
+
...(pageLine[2] === undefined ? {} : { page: Number(pageLine[2]) }),
|
|
67
|
+
...(pageLine[3] === undefined ? {} : { pageEnd: Number(pageLine[3]) }),
|
|
68
|
+
text: heading?.[2] ?? text,
|
|
69
|
+
...(heading ? { heading: heading[1].length } : {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const heading = clean.match(/^(#{1,6})\s+(.+)$/u)
|
|
73
|
+
return heading ? { text: heading[2], heading: heading[1].length } : { text: clean }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseBlock(value) {
|
|
77
|
+
const references = []
|
|
78
|
+
const links = []
|
|
79
|
+
const rawLines = String(value ?? '')
|
|
80
|
+
.replace(EMBEDDED_PAGE_LINE_PATTERN, '\n')
|
|
81
|
+
.split(/\r?\n/u)
|
|
82
|
+
.map((line) => line.trimEnd())
|
|
83
|
+
while (rawLines[0]?.trim() === '') rawLines.shift()
|
|
84
|
+
while (rawLines.at(-1)?.trim() === '') rawLines.pop()
|
|
85
|
+
if (rawLines.length === 0) return undefined
|
|
86
|
+
|
|
87
|
+
const firstLine = cleanCitations(rawLines[0], references, links)
|
|
88
|
+
const header = firstLine.match(TITLE_URL_PATTERN)
|
|
89
|
+
const titleOnly = firstLine.match(/^(.+?)\s*\(\)\s*$/u)
|
|
90
|
+
let title = header?.[1]?.trim() || titleOnly?.[1]?.trim() || undefined
|
|
91
|
+
const url = header?.[2]
|
|
92
|
+
let bodyStart = header || titleOnly ? 1 : 0
|
|
93
|
+
if (!header && /^\s*\([^)]*\)\s*$/u.test(firstLine)) bodyStart = 1
|
|
94
|
+
|
|
95
|
+
const metadata = []
|
|
96
|
+
while (bodyStart < rawLines.length) {
|
|
97
|
+
const clean = cleanCitations(rawLines[bodyStart], references, links)
|
|
98
|
+
if (!isMetadata(clean)) break
|
|
99
|
+
metadata.push(...metadataParts(clean))
|
|
100
|
+
bodyStart += 1
|
|
101
|
+
}
|
|
102
|
+
const lines = rawLines.slice(bodyStart).map((line) => parseLine(line, references, links))
|
|
103
|
+
if (!title && url) {
|
|
104
|
+
try { title = new URL(url).hostname } catch { title = url }
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...(title ? { title } : {}),
|
|
108
|
+
...(url ? { url } : {}),
|
|
109
|
+
references,
|
|
110
|
+
links,
|
|
111
|
+
metadata,
|
|
112
|
+
lines,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseWebRunOutput(output) {
|
|
117
|
+
if (typeof output !== 'string' || output.length === 0) return []
|
|
118
|
+
return output.split(RESULT_SEPARATOR_PATTERN).map(parseBlock).filter(Boolean)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function outputLineRange(blocks) {
|
|
122
|
+
const numbers = (blocks ?? []).flatMap((block) => (block.lines ?? []).flatMap((line) => line.line === undefined ? [] : [line.line]))
|
|
123
|
+
if (numbers.length === 0) return undefined
|
|
124
|
+
return { first: Math.min(...numbers), last: Math.max(...numbers) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function outputDomains(blocks) {
|
|
128
|
+
const domains = []
|
|
129
|
+
for (const block of blocks ?? []) {
|
|
130
|
+
if (!block.url) continue
|
|
131
|
+
try { pushUnique(domains, new URL(block.url).hostname) } catch { /* ignore malformed source URLs */ }
|
|
132
|
+
}
|
|
133
|
+
return domains
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function outputLinks(blocks) {
|
|
137
|
+
const links = []
|
|
138
|
+
for (const block of blocks ?? []) {
|
|
139
|
+
for (const link of block.links ?? []) {
|
|
140
|
+
if (!links.some((value) => value.id === link.id)) links.push({ ...link })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return links
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function outputPdfRefs(blocks) {
|
|
147
|
+
const refs = []
|
|
148
|
+
for (const block of blocks ?? []) {
|
|
149
|
+
if (!(block.metadata ?? []).includes('PDF')) continue
|
|
150
|
+
for (const ref of block.references ?? []) pushUnique(refs, ref)
|
|
151
|
+
}
|
|
152
|
+
return refs
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function blockPlainText(block) {
|
|
156
|
+
return (block?.lines ?? []).map((line) => line.text).filter(Boolean).join(' ')
|
|
157
|
+
}
|