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.
@@ -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 }
@@ -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
+ }