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,383 @@
|
|
|
1
|
+
import { outputDomains, outputLineRange, parseWebRunOutput } from './web-run-output.js'
|
|
2
|
+
|
|
3
|
+
export const HOSTED_SEARCH_PARAMETERS = {
|
|
4
|
+
type: 'object',
|
|
5
|
+
properties: {
|
|
6
|
+
query: {
|
|
7
|
+
type: 'string',
|
|
8
|
+
description: 'Search the web for the requested topic. Include date or freshness requirements in the query when needed.',
|
|
9
|
+
},
|
|
10
|
+
searchContextSize: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
11
|
+
allowedDomains: { type: 'array', items: { type: 'string' } },
|
|
12
|
+
blockedDomains: { type: 'array', items: { type: 'string' } },
|
|
13
|
+
userLocation: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
country: { type: 'string' },
|
|
17
|
+
city: { type: 'string' },
|
|
18
|
+
region: { type: 'string' },
|
|
19
|
+
timezone: { type: 'string' },
|
|
20
|
+
},
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
},
|
|
23
|
+
externalWebAccess: { type: 'boolean' },
|
|
24
|
+
returnTokenBudget: { type: 'string', enum: ['default', 'unlimited'] },
|
|
25
|
+
searchContentTypes: {
|
|
26
|
+
type: 'array',
|
|
27
|
+
items: { type: 'string', enum: ['text', 'image'] },
|
|
28
|
+
},
|
|
29
|
+
imageSettings: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
maxResults: { type: 'integer' },
|
|
33
|
+
caption: { type: 'boolean' },
|
|
34
|
+
},
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ['query'],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const HOSTED_SEARCH_OUTPUT = {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
mode: { type: 'string', enum: ['hosted'] },
|
|
46
|
+
action: { type: 'string' },
|
|
47
|
+
emulation: { type: 'string', enum: ['native'] },
|
|
48
|
+
content: { type: 'string' },
|
|
49
|
+
sources: { type: 'array', items: { type: 'object' } },
|
|
50
|
+
citations: { type: 'array', items: { type: 'object' } },
|
|
51
|
+
images: { type: 'array', items: { type: 'object' } },
|
|
52
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
53
|
+
outputBlocks: { type: 'array', items: { type: 'object' } },
|
|
54
|
+
domains: { type: 'array', items: { type: 'string' } },
|
|
55
|
+
lineRange: { type: 'object' },
|
|
56
|
+
requestId: { type: 'string' },
|
|
57
|
+
responseId: { type: 'string' },
|
|
58
|
+
retrievedAt: { type: 'string' },
|
|
59
|
+
truncated: { type: 'boolean' },
|
|
60
|
+
},
|
|
61
|
+
required: ['mode', 'action', 'emulation', 'content', 'sources', 'citations', 'images', 'warnings', 'requestId', 'retrievedAt', 'truncated'],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function failure(message, code) {
|
|
66
|
+
const error = new Error(message)
|
|
67
|
+
error.code = code
|
|
68
|
+
return error
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeHostedSearchArgs(args) {
|
|
72
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) throw failure('websearch_gpt arguments must be an object', 'WEB_INVALID_REQUEST')
|
|
73
|
+
if (typeof args.query !== 'string' || args.query.trim().length === 0) throw failure('websearch_gpt.query must be a non-empty string', 'WEB_INVALID_REQUEST')
|
|
74
|
+
const known = new Set([
|
|
75
|
+
'query',
|
|
76
|
+
'searchContextSize',
|
|
77
|
+
'allowedDomains',
|
|
78
|
+
'blockedDomains',
|
|
79
|
+
'userLocation',
|
|
80
|
+
'externalWebAccess',
|
|
81
|
+
'returnTokenBudget',
|
|
82
|
+
'searchContentTypes',
|
|
83
|
+
'imageSettings',
|
|
84
|
+
])
|
|
85
|
+
if (Object.keys(args).some((key) => !known.has(key))) throw failure('websearch_gpt received an unknown field', 'WEB_INVALID_REQUEST')
|
|
86
|
+
const normalized = { query: args.query.trim() }
|
|
87
|
+
if (normalized.query.length > 16_000) throw failure('websearch_gpt.query is too long', 'WEB_INVALID_REQUEST')
|
|
88
|
+
if (args.searchContextSize !== undefined) {
|
|
89
|
+
if (!['low', 'medium', 'high'].includes(args.searchContextSize)) throw failure('websearch_gpt.searchContextSize is invalid', 'WEB_INVALID_REQUEST')
|
|
90
|
+
normalized.searchContextSize = args.searchContextSize
|
|
91
|
+
}
|
|
92
|
+
const allowedDomains = normalizeDomains(args.allowedDomains, 'allowedDomains')
|
|
93
|
+
const blockedDomains = normalizeDomains(args.blockedDomains, 'blockedDomains')
|
|
94
|
+
if (allowedDomains) normalized.allowedDomains = allowedDomains
|
|
95
|
+
if (blockedDomains) normalized.blockedDomains = blockedDomains
|
|
96
|
+
if (allowedDomains && blockedDomains) {
|
|
97
|
+
const blocked = new Set(blockedDomains)
|
|
98
|
+
if (allowedDomains.some((domain) => blocked.has(domain))) throw failure('websearch_gpt domain filters conflict', 'WEB_INVALID_REQUEST')
|
|
99
|
+
}
|
|
100
|
+
if (args.userLocation !== undefined) normalized.userLocation = normalizeUserLocation(args.userLocation)
|
|
101
|
+
if (args.externalWebAccess !== undefined) {
|
|
102
|
+
if (typeof args.externalWebAccess !== 'boolean') throw failure('websearch_gpt.externalWebAccess must be boolean', 'WEB_INVALID_REQUEST')
|
|
103
|
+
normalized.externalWebAccess = args.externalWebAccess
|
|
104
|
+
}
|
|
105
|
+
if (args.returnTokenBudget !== undefined) {
|
|
106
|
+
if (!['default', 'unlimited'].includes(args.returnTokenBudget)) throw failure('websearch_gpt.returnTokenBudget is invalid', 'WEB_INVALID_REQUEST')
|
|
107
|
+
normalized.returnTokenBudget = args.returnTokenBudget
|
|
108
|
+
}
|
|
109
|
+
if (args.searchContentTypes !== undefined) normalized.searchContentTypes = normalizeContentTypes(args.searchContentTypes)
|
|
110
|
+
if (args.imageSettings !== undefined) {
|
|
111
|
+
if (!normalized.searchContentTypes?.includes('image')) throw failure('websearch_gpt.imageSettings requires image search content', 'WEB_INVALID_REQUEST')
|
|
112
|
+
normalized.imageSettings = normalizeImageSettings(args.imageSettings)
|
|
113
|
+
}
|
|
114
|
+
return normalized
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeDomains(value, field) {
|
|
118
|
+
if (value === undefined) return undefined
|
|
119
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) throw failure(`websearch_gpt.${field} must contain 1 to 100 domains`, 'WEB_INVALID_REQUEST')
|
|
120
|
+
const domains = value.map((item) => {
|
|
121
|
+
if (typeof item !== 'string') throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
|
|
122
|
+
const domain = item.trim().toLowerCase()
|
|
123
|
+
if (domain.length === 0 || domain.length > 253 || domain.includes('/') || domain.includes(':') || domain.endsWith('.')) {
|
|
124
|
+
throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
|
|
125
|
+
}
|
|
126
|
+
const labels = domain.split('.')
|
|
127
|
+
if (labels.length < 2 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))) {
|
|
128
|
+
throw failure(`websearch_gpt.${field} contains an invalid domain`, 'WEB_INVALID_REQUEST')
|
|
129
|
+
}
|
|
130
|
+
return domain
|
|
131
|
+
})
|
|
132
|
+
if (new Set(domains).size !== domains.length) throw failure(`websearch_gpt.${field} contains duplicate domains`, 'WEB_INVALID_REQUEST')
|
|
133
|
+
return domains
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeUserLocation(value) {
|
|
137
|
+
if (!isRecord(value)) throw failure('websearch_gpt.userLocation must be an object', 'WEB_INVALID_REQUEST')
|
|
138
|
+
const known = new Set(['country', 'city', 'region', 'timezone'])
|
|
139
|
+
if (Object.keys(value).some((key) => !known.has(key))) throw failure('websearch_gpt.userLocation contains an unknown field', 'WEB_INVALID_REQUEST')
|
|
140
|
+
const result = {}
|
|
141
|
+
if (value.country !== undefined) {
|
|
142
|
+
if (typeof value.country !== 'string' || !/^[a-z]{2}$/iu.test(value.country.trim())) throw failure('websearch_gpt.userLocation.country must be an ISO alpha-2 code', 'WEB_INVALID_REQUEST')
|
|
143
|
+
result.country = value.country.trim().toUpperCase()
|
|
144
|
+
}
|
|
145
|
+
for (const field of ['city', 'region']) {
|
|
146
|
+
if (value[field] === undefined) continue
|
|
147
|
+
if (typeof value[field] !== 'string' || value[field].trim().length === 0 || value[field].trim().length > 200) throw failure(`websearch_gpt.userLocation.${field} is invalid`, 'WEB_INVALID_REQUEST')
|
|
148
|
+
result[field] = value[field].trim()
|
|
149
|
+
}
|
|
150
|
+
if (value.timezone !== undefined) {
|
|
151
|
+
if (typeof value.timezone !== 'string' || value.timezone.trim().length === 0) throw failure('websearch_gpt.userLocation.timezone is invalid', 'WEB_INVALID_REQUEST')
|
|
152
|
+
const timezone = value.timezone.trim()
|
|
153
|
+
try {
|
|
154
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format()
|
|
155
|
+
} catch {
|
|
156
|
+
throw failure('websearch_gpt.userLocation.timezone must be an IANA timezone', 'WEB_INVALID_REQUEST')
|
|
157
|
+
}
|
|
158
|
+
result.timezone = timezone
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(result).length === 0) throw failure('websearch_gpt.userLocation must contain a location field', 'WEB_INVALID_REQUEST')
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeContentTypes(value) {
|
|
165
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 2 || value.some((item) => !['text', 'image'].includes(item))) {
|
|
166
|
+
throw failure('websearch_gpt.searchContentTypes is invalid', 'WEB_INVALID_REQUEST')
|
|
167
|
+
}
|
|
168
|
+
if (new Set(value).size !== value.length) throw failure('websearch_gpt.searchContentTypes contains duplicates', 'WEB_INVALID_REQUEST')
|
|
169
|
+
return [...value]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeImageSettings(value) {
|
|
173
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !['maxResults', 'caption'].includes(key))) throw failure('websearch_gpt.imageSettings is invalid', 'WEB_INVALID_REQUEST')
|
|
174
|
+
const result = {}
|
|
175
|
+
if (value.maxResults !== undefined) {
|
|
176
|
+
if (!Number.isInteger(value.maxResults) || value.maxResults < 1 || value.maxResults > 100) throw failure('websearch_gpt.imageSettings.maxResults is invalid', 'WEB_INVALID_REQUEST')
|
|
177
|
+
result.maxResults = value.maxResults
|
|
178
|
+
}
|
|
179
|
+
if (value.caption !== undefined) {
|
|
180
|
+
if (typeof value.caption !== 'boolean') throw failure('websearch_gpt.imageSettings.caption must be boolean', 'WEB_INVALID_REQUEST')
|
|
181
|
+
result.caption = value.caption
|
|
182
|
+
}
|
|
183
|
+
if (Object.keys(result).length === 0) throw failure('websearch_gpt.imageSettings must contain a setting', 'WEB_INVALID_REQUEST')
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function buildHostedSearchBody(args, model) {
|
|
188
|
+
const tool = {
|
|
189
|
+
type: 'web_search',
|
|
190
|
+
...(args.searchContextSize ? { search_context_size: args.searchContextSize } : {}),
|
|
191
|
+
...(args.allowedDomains || args.blockedDomains ? {
|
|
192
|
+
filters: {
|
|
193
|
+
...(args.allowedDomains ? { allowed_domains: args.allowedDomains } : {}),
|
|
194
|
+
...(args.blockedDomains ? { blocked_domains: args.blockedDomains } : {}),
|
|
195
|
+
},
|
|
196
|
+
} : {}),
|
|
197
|
+
...(args.userLocation ? { user_location: { type: 'approximate', ...args.userLocation } } : {}),
|
|
198
|
+
...(args.externalWebAccess !== undefined ? { external_web_access: args.externalWebAccess } : {}),
|
|
199
|
+
...(args.returnTokenBudget ? { return_token_budget: args.returnTokenBudget } : {}),
|
|
200
|
+
...(args.searchContentTypes ? { search_content_types: args.searchContentTypes } : {}),
|
|
201
|
+
...(args.imageSettings ? {
|
|
202
|
+
image_settings: {
|
|
203
|
+
...(args.imageSettings.maxResults !== undefined ? { max_results: args.imageSettings.maxResults } : {}),
|
|
204
|
+
...(args.imageSettings.caption !== undefined ? { caption: args.imageSettings.caption } : {}),
|
|
205
|
+
},
|
|
206
|
+
} : {}),
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
model,
|
|
210
|
+
input: [{ role: 'user', content: [{ type: 'input_text', text: args.query }] }],
|
|
211
|
+
tools: [tool],
|
|
212
|
+
tool_choice: 'required',
|
|
213
|
+
include: [
|
|
214
|
+
'web_search_call.action.sources',
|
|
215
|
+
...(args.searchContentTypes?.includes('image') ? ['web_search_call.results'] : []),
|
|
216
|
+
],
|
|
217
|
+
stream: false,
|
|
218
|
+
store: false,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isRecord(value) {
|
|
223
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function textFrom(value, seen = new Set()) {
|
|
227
|
+
if (typeof value === 'string') return value
|
|
228
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return ''
|
|
229
|
+
seen.add(value)
|
|
230
|
+
if (Array.isArray(value)) return value.map((item) => textFrom(item, seen)).filter(Boolean).join('\n')
|
|
231
|
+
if (typeof value.text === 'string' && ['output_text', 'text', 'input_text'].includes(value.type)) return value.text
|
|
232
|
+
if (Array.isArray(value.content)) return textFrom(value.content, seen)
|
|
233
|
+
return ''
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sourceFrom(value) {
|
|
237
|
+
if (!isRecord(value) || typeof value.url !== 'string' || value.url.trim().length === 0) return undefined
|
|
238
|
+
return {
|
|
239
|
+
url: value.url,
|
|
240
|
+
...(typeof value.title === 'string' && value.title ? { title: value.title } : {}),
|
|
241
|
+
...(typeof value.snippet === 'string' && value.snippet ? { snippet: value.snippet } : {}),
|
|
242
|
+
...(typeof value.publishedAt === 'string' && value.publishedAt ? { publishedAt: value.publishedAt } : {}),
|
|
243
|
+
...(typeof value.published_at === 'string' && value.published_at ? { publishedAt: value.published_at } : {}),
|
|
244
|
+
...(typeof value.ref_id === 'string' && value.ref_id ? { refId: value.ref_id } : {}),
|
|
245
|
+
...(typeof value.domain === 'string' && value.domain ? { domain: value.domain } : {}),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function responseArtifacts(response) {
|
|
250
|
+
const sources = []
|
|
251
|
+
const citations = []
|
|
252
|
+
const images = []
|
|
253
|
+
const actions = []
|
|
254
|
+
for (const item of Array.isArray(response?.output) ? response.output : []) {
|
|
255
|
+
if (item?.type === 'web_search_call') {
|
|
256
|
+
actions.push(typeof item.action?.type === 'string' ? item.action.type : 'search')
|
|
257
|
+
if (Array.isArray(item.action?.sources)) {
|
|
258
|
+
for (const value of item.action.sources) {
|
|
259
|
+
const source = sourceFrom(value)
|
|
260
|
+
if (source) sources.push(source)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const value of Array.isArray(item.results) ? item.results : []) {
|
|
264
|
+
const image = imageFrom(value)
|
|
265
|
+
if (image) images.push(image)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (item?.type !== 'message') continue
|
|
269
|
+
for (const part of Array.isArray(item.content) ? item.content : []) {
|
|
270
|
+
if (part?.type !== 'output_text') continue
|
|
271
|
+
for (const annotation of Array.isArray(part.annotations) ? part.annotations : []) {
|
|
272
|
+
if (annotation?.type !== 'url_citation') continue
|
|
273
|
+
const source = sourceFrom(annotation)
|
|
274
|
+
if (source) {
|
|
275
|
+
citations.push(source)
|
|
276
|
+
sources.push(source)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { sources, citations, images, actions }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function httpUrl(value) {
|
|
285
|
+
try {
|
|
286
|
+
const url = new URL(value)
|
|
287
|
+
if (!['http:', 'https:'].includes(url.protocol)) return undefined
|
|
288
|
+
return url
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function imageFrom(value) {
|
|
295
|
+
if (!isRecord(value) || value.type !== 'image_result') return undefined
|
|
296
|
+
const imageUrl = httpUrl(value.image_url)?.toString()
|
|
297
|
+
if (!imageUrl) return undefined
|
|
298
|
+
const thumbnailUrl = httpUrl(value.thumbnail_url)?.toString()
|
|
299
|
+
const sourceWebsiteUrl = httpUrl(value.source_website_url)?.toString()
|
|
300
|
+
return {
|
|
301
|
+
imageUrl,
|
|
302
|
+
...(thumbnailUrl ? { thumbnailUrl } : {}),
|
|
303
|
+
...(sourceWebsiteUrl ? { sourceWebsiteUrl } : {}),
|
|
304
|
+
...(typeof value.caption === 'string' && value.caption ? { caption: value.caption } : {}),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function canonicalUrl(value) {
|
|
309
|
+
const url = httpUrl(value)
|
|
310
|
+
if (url) {
|
|
311
|
+
url.hash = ''
|
|
312
|
+
for (const key of [...url.searchParams.keys()]) if (/^(utm_|gclid$|fbclid$)/iu.test(key)) url.searchParams.delete(key)
|
|
313
|
+
return url.toString()
|
|
314
|
+
}
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function uniqueByUrl(values) {
|
|
319
|
+
const result = []
|
|
320
|
+
const seen = new Set()
|
|
321
|
+
for (const value of values) {
|
|
322
|
+
const key = canonicalUrl(value.url)
|
|
323
|
+
if (!key || seen.has(key)) continue
|
|
324
|
+
seen.add(key)
|
|
325
|
+
result.push(value)
|
|
326
|
+
}
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function parseHostedSearchResponse(response, requestId, maxResults = 8, retrievedAt = new Date().toISOString()) {
|
|
331
|
+
if (response?.error) {
|
|
332
|
+
const error = failure(response.error.message ?? 'LCX hosted Web Search failed', 'LCX_WEB_PROVIDER_ERROR')
|
|
333
|
+
throw error
|
|
334
|
+
}
|
|
335
|
+
if (response?.status !== 'completed') {
|
|
336
|
+
throw failure(`LCX hosted Web Search returned response status: ${String(response?.status ?? 'missing')}`, 'WEB_RESPONSE_INCOMPLETE')
|
|
337
|
+
}
|
|
338
|
+
const output = typeof response?.output_text === 'string' ? response.output_text : textFrom(response?.output ?? response?.content)
|
|
339
|
+
const outputBlocks = parseWebRunOutput(output)
|
|
340
|
+
const artifacts = responseArtifacts(response)
|
|
341
|
+
if (artifacts.actions.length === 0) throw failure('LCX hosted Web Search completed without executing web_search', 'WEB_SEARCH_NOT_EXECUTED')
|
|
342
|
+
const allSources = [...artifacts.sources]
|
|
343
|
+
for (const block of outputBlocks) {
|
|
344
|
+
if (block.url) allSources.push({ url: block.url, ...(block.title ? { title: block.title } : {}) })
|
|
345
|
+
}
|
|
346
|
+
const sources = uniqueByUrl(allSources)
|
|
347
|
+
const citations = uniqueByUrl(artifacts.citations)
|
|
348
|
+
const limited = sources.slice(0, Math.max(1, maxResults))
|
|
349
|
+
const images = artifacts.images.slice(0, Math.max(1, maxResults))
|
|
350
|
+
if (!output && limited.length === 0 && images.length === 0) throw failure('LCX hosted Web Search returned no text, sources, or images', 'WEB_NO_SOURCES')
|
|
351
|
+
return {
|
|
352
|
+
mode: 'hosted',
|
|
353
|
+
action: artifacts.actions[0],
|
|
354
|
+
emulation: 'native',
|
|
355
|
+
content: output,
|
|
356
|
+
sources: limited,
|
|
357
|
+
citations,
|
|
358
|
+
images,
|
|
359
|
+
warnings: artifacts.actions.length > 1 ? [`Multiple hosted search actions were returned: ${artifacts.actions.join(', ')}`] : [],
|
|
360
|
+
outputBlocks,
|
|
361
|
+
domains: outputDomains(outputBlocks),
|
|
362
|
+
...(outputLineRange(outputBlocks) ? { lineRange: outputLineRange(outputBlocks) } : {}),
|
|
363
|
+
requestId,
|
|
364
|
+
...(typeof response?.id === 'string' && response.id ? { responseId: response.id } : {}),
|
|
365
|
+
retrievedAt,
|
|
366
|
+
truncated: sources.length > limited.length || artifacts.images.length > images.length,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function renderHostedSearchResult(value) {
|
|
371
|
+
const parts = []
|
|
372
|
+
if (value.content) parts.push(value.content)
|
|
373
|
+
if (value.sources?.length) {
|
|
374
|
+
parts.push(`来源:\n${value.sources.map((source) => `- [${source.title ?? source.url}](${source.url})${source.publishedAt ? `(${source.publishedAt})` : ''}${source.snippet ? ` — ${source.snippet}` : ''}`).join('\n')}`)
|
|
375
|
+
}
|
|
376
|
+
if (value.images?.length) {
|
|
377
|
+
parts.push(`图片:\n${value.images.map((image) => `- [${image.caption ?? image.imageUrl}](${image.imageUrl})${image.sourceWebsiteUrl ? `([来源页面](${image.sourceWebsiteUrl}))` : ''}`).join('\n')}`)
|
|
378
|
+
}
|
|
379
|
+
if (value.warnings?.length) parts.push(value.warnings.map((warning) => `警告:${warning}`).join('\n'))
|
|
380
|
+
if (value.retrievedAt) parts.push(`检索时间:${value.retrievedAt}`)
|
|
381
|
+
if (value.truncated) parts.push('来源已截断;如需更多结果,请缩小查询范围。')
|
|
382
|
+
return [{ type: 'text', text: parts.join('\n\n') || '没有找到结果。' }]
|
|
383
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AtomicWebSearchStore } from './web-search-store.js'
|
|
2
|
+
|
|
3
|
+
const VERSION = 1
|
|
4
|
+
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function validHttpUrl(value) {
|
|
10
|
+
if (value === undefined) return true
|
|
11
|
+
try {
|
|
12
|
+
return ['http:', 'https:'].includes(new URL(value).protocol)
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validData(data) {
|
|
19
|
+
if (data?.version !== VERSION || !isRecord(data.sessions)) return false
|
|
20
|
+
return Object.entries(data.sessions).every(([sessionId, session]) =>
|
|
21
|
+
sessionId.length > 0 && isRecord(session) && typeof session.routeFingerprint === 'string' && session.routeFingerprint.length > 0 &&
|
|
22
|
+
typeof session.updatedAt === 'string' && !Number.isNaN(Date.parse(session.updatedAt)) && isRecord(session.refs) &&
|
|
23
|
+
Object.entries(session.refs).every(([refId, ref]) => refId.length > 0 && isRecord(ref) && ref.refId === refId && validHttpUrl(ref.url)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function unavailable(refId) {
|
|
27
|
+
const error = new Error(`Alpha reference is unavailable in this session and route: ${String(refId)}`)
|
|
28
|
+
error.code = 'LCX_ALPHA_REF_UNAVAILABLE'
|
|
29
|
+
return error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AlphaRefStore {
|
|
33
|
+
constructor(file) {
|
|
34
|
+
this.store = new AtomicWebSearchStore(file, {
|
|
35
|
+
empty: () => ({ version: VERSION, sessions: {} }),
|
|
36
|
+
validate: validData,
|
|
37
|
+
corruptCode: 'LCX_ALPHA_REF_STORE_CORRUPT',
|
|
38
|
+
writeCode: 'LCX_ALPHA_REF_STORE_WRITE_FAILED',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
record(sessionId, routeFingerprint, refs) {
|
|
43
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof routeFingerprint !== 'string' || routeFingerprint.length === 0 || !Array.isArray(refs)) {
|
|
44
|
+
throw unavailable('invalid-record')
|
|
45
|
+
}
|
|
46
|
+
this.store.update((current) => {
|
|
47
|
+
const previous = current.sessions[sessionId]
|
|
48
|
+
const previousRefs = previous?.routeFingerprint === routeFingerprint ? previous.refs : {}
|
|
49
|
+
const nextRefs = { ...previousRefs }
|
|
50
|
+
for (const value of refs) {
|
|
51
|
+
if (!isRecord(value) || typeof value.refId !== 'string' || value.refId.length === 0 || !validHttpUrl(value.url)) continue
|
|
52
|
+
nextRefs[value.refId] = { refId: value.refId, ...(value.url ? { url: value.url } : {}) }
|
|
53
|
+
}
|
|
54
|
+
const sessions = {
|
|
55
|
+
...current.sessions,
|
|
56
|
+
[sessionId]: { routeFingerprint, refs: nextRefs, updatedAt: new Date().toISOString() },
|
|
57
|
+
}
|
|
58
|
+
const ordered = Object.entries(sessions).sort((left, right) => Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt)).slice(0, 256)
|
|
59
|
+
return { version: VERSION, sessions: Object.fromEntries(ordered) }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
assertUsable(sessionId, routeFingerprint, refId) {
|
|
64
|
+
this.store.refresh()
|
|
65
|
+
const session = this.store.data.sessions[sessionId]
|
|
66
|
+
const ref = session?.routeFingerprint === routeFingerprint ? session.refs?.[refId] : undefined
|
|
67
|
+
if (!ref) throw unavailable(refId)
|
|
68
|
+
return structuredClone(ref)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { ensurePrivateFileAcl } from './private-file.js'
|
|
5
|
+
|
|
6
|
+
const LOCK_STALE_MS = 30 * 1000
|
|
7
|
+
const LOCK_WAIT_MS = 5 * 1000
|
|
8
|
+
|
|
9
|
+
function sleepSync(milliseconds) {
|
|
10
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function processAlive(pid) {
|
|
14
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0)
|
|
17
|
+
return true
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return error?.code === 'EPERM'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function staleLockSnapshot(file) {
|
|
24
|
+
try {
|
|
25
|
+
const info = statSync(file)
|
|
26
|
+
const text = readFileSync(file, 'utf8')
|
|
27
|
+
const [pidText, startedAtText] = text.split(/\r?\n/u)
|
|
28
|
+
const pid = Number(pidText)
|
|
29
|
+
const startedAt = Number(startedAtText)
|
|
30
|
+
const age = Date.now() - Math.max(info.mtimeMs, Number.isFinite(startedAt) ? startedAt : 0)
|
|
31
|
+
if (age <= LOCK_STALE_MS || processAlive(pid)) return undefined
|
|
32
|
+
return { text, mtimeMs: info.mtimeMs, size: info.size }
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unlinkLockIfUnchanged(file, snapshot) {
|
|
39
|
+
if (!snapshot) return false
|
|
40
|
+
try {
|
|
41
|
+
const info = statSync(file)
|
|
42
|
+
if (info.mtimeMs !== snapshot.mtimeMs || info.size !== snapshot.size || readFileSync(file, 'utf8') !== snapshot.text) return false
|
|
43
|
+
unlinkSync(file)
|
|
44
|
+
return true
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function signature(file) {
|
|
51
|
+
try {
|
|
52
|
+
const value = statSync(file)
|
|
53
|
+
return `${value.mtimeMs}:${value.ctimeMs}:${value.size}`
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.code === 'ENOENT') return undefined
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class AtomicWebSearchStore {
|
|
61
|
+
constructor(file, { empty, validate, corruptCode, writeCode, maxBytes = 2 * 1024 * 1024 }) {
|
|
62
|
+
this.file = file
|
|
63
|
+
this.lockFile = `${file}.lock`
|
|
64
|
+
this.empty = empty
|
|
65
|
+
this.validate = validate
|
|
66
|
+
this.corruptCode = corruptCode
|
|
67
|
+
this.writeCode = writeCode
|
|
68
|
+
this.maxBytes = maxBytes
|
|
69
|
+
this.fileSignature = undefined
|
|
70
|
+
this.data = this.load()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
error(message, code, cause) {
|
|
74
|
+
const error = new Error(message, cause === undefined ? undefined : { cause })
|
|
75
|
+
error.code = code
|
|
76
|
+
return error
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
load(repairAcl = true) {
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(this.file, 'utf8')
|
|
82
|
+
if (Buffer.byteLength(text, 'utf8') > this.maxBytes) throw this.error(`Web Search store exceeds ${this.maxBytes} bytes`, this.corruptCode)
|
|
83
|
+
const parsed = JSON.parse(text)
|
|
84
|
+
if (!this.validate(parsed)) throw this.error(`Invalid Web Search store ${this.file}`, this.corruptCode)
|
|
85
|
+
this.fileSignature = signature(this.file)
|
|
86
|
+
return parsed
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.code === 'ENOENT') {
|
|
89
|
+
this.fileSignature = undefined
|
|
90
|
+
return this.empty()
|
|
91
|
+
}
|
|
92
|
+
if (repairAcl && ['EACCES', 'EPERM'].includes(error?.code) && ensurePrivateFileAcl(this.file)) {
|
|
93
|
+
return this.load(false)
|
|
94
|
+
}
|
|
95
|
+
if (error?.code === this.corruptCode) throw error
|
|
96
|
+
throw this.error(`Unable to load Web Search store ${this.file}`, this.corruptCode, error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
refresh() {
|
|
101
|
+
if (signature(this.file) !== this.fileSignature) this.data = this.load()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
update(mutator) {
|
|
105
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
106
|
+
ensurePrivateFileAcl(dirname(this.file))
|
|
107
|
+
const release = this.acquireLock()
|
|
108
|
+
try {
|
|
109
|
+
const current = this.load()
|
|
110
|
+
const next = mutator(structuredClone(current))
|
|
111
|
+
this.saveUnlocked(next)
|
|
112
|
+
return structuredClone(next)
|
|
113
|
+
} finally {
|
|
114
|
+
release()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
acquireLock() {
|
|
119
|
+
const started = Date.now()
|
|
120
|
+
while (Date.now() - started <= LOCK_WAIT_MS) {
|
|
121
|
+
const token = randomUUID()
|
|
122
|
+
const lockText = `${process.pid}\n${Date.now()}\n${token}\n`
|
|
123
|
+
let fd
|
|
124
|
+
let created = false
|
|
125
|
+
try {
|
|
126
|
+
fd = openSync(this.lockFile, 'wx', 0o600)
|
|
127
|
+
created = true
|
|
128
|
+
if (!ensurePrivateFileAcl(this.lockFile)) throw new Error(`Unable to apply private ACL to Web Search store lock ${this.lockFile}`)
|
|
129
|
+
writeFileSync(fd, lockText, { encoding: 'utf8' })
|
|
130
|
+
fsyncSync(fd)
|
|
131
|
+
closeSync(fd)
|
|
132
|
+
fd = undefined
|
|
133
|
+
return () => {
|
|
134
|
+
try {
|
|
135
|
+
if (readFileSync(this.lockFile, 'utf8') === lockText) unlinkSync(this.lockFile)
|
|
136
|
+
} catch {
|
|
137
|
+
// A crashed owner may have left a lock that was recovered by another writer.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (fd !== undefined) {
|
|
142
|
+
try { closeSync(fd) } catch {}
|
|
143
|
+
}
|
|
144
|
+
if (created) {
|
|
145
|
+
try { unlinkSync(this.lockFile) } catch {}
|
|
146
|
+
}
|
|
147
|
+
if (error?.code !== 'EEXIST') throw this.error(`Unable to acquire Web Search store lock ${this.lockFile}`, this.writeCode, error)
|
|
148
|
+
if (unlinkLockIfUnchanged(this.lockFile, staleLockSnapshot(this.lockFile))) continue
|
|
149
|
+
sleepSync(10)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw this.error(`Timed out acquiring Web Search store lock ${this.lockFile}`, this.writeCode)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
saveUnlocked(data) {
|
|
156
|
+
if (!this.validate(data)) throw this.error(`Refusing to write invalid Web Search store ${this.file}`, this.writeCode)
|
|
157
|
+
const serialized = JSON.stringify(data, null, 2)
|
|
158
|
+
if (Buffer.byteLength(serialized, 'utf8') > this.maxBytes) throw this.error(`Web Search store exceeds ${this.maxBytes} bytes`, this.writeCode)
|
|
159
|
+
const directory = dirname(this.file)
|
|
160
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomUUID()}.tmp`)
|
|
161
|
+
let fd
|
|
162
|
+
try {
|
|
163
|
+
fd = openSync(temporary, 'wx', 0o600)
|
|
164
|
+
if (!ensurePrivateFileAcl(temporary)) throw new Error(`Unable to apply private ACL to Web Search store temporary file ${temporary}`)
|
|
165
|
+
writeFileSync(fd, serialized, { encoding: 'utf8' })
|
|
166
|
+
fsyncSync(fd)
|
|
167
|
+
closeSync(fd)
|
|
168
|
+
fd = undefined
|
|
169
|
+
renameSync(temporary, this.file)
|
|
170
|
+
ensurePrivateFileAcl(this.file)
|
|
171
|
+
this.fileSignature = signature(this.file)
|
|
172
|
+
this.data = data
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (fd !== undefined) {
|
|
175
|
+
try { closeSync(fd) } catch {}
|
|
176
|
+
}
|
|
177
|
+
try { unlinkSync(temporary) } catch {}
|
|
178
|
+
throw this.error(`Unable to atomically write Web Search store ${this.file}`, this.writeCode, error)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|