dsh-reference-anything 0.5.0 → 0.5.2
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/LICENSE +22 -22
- package/NOTICE.md +110 -110
- package/README.md +470 -451
- package/cordis.patch.yml +136 -134
- package/docs/release/0.4.4/README.md +75 -0
- package/{release-notes → docs/release}/0.4.4/features.json +1 -1
- package/docs/release/0.5.0/README.md +148 -0
- package/docs/release/0.5.1/README.md +33 -0
- package/docs/release/0.5.2/README.md +51 -0
- package/docs/release/0.5.2/features.json +29 -0
- package/{release-notes → docs/release}/README.md +12 -6
- package/images/agents/LICENSE +22 -22
- package/images/agents/README.md +7 -3
- package/images/agents/antigravity.svg +1 -1
- package/images/agents/cline.svg +1 -1
- package/images/agents/continue.svg +1 -1
- package/images/agents/copilot.svg +27 -0
- package/images/agents/crush.svg +1 -1
- package/images/agents/doubao.svg +1 -0
- package/images/agents/goose.svg +1 -1
- package/images/agents/omp.svg +1 -0
- package/images/agents/reasonix.svg +5 -5
- package/images/agents/teleagent.svg +1 -1
- package/images/agents/trae.svg +1 -1
- package/images/agents/vibe.svg +1 -1
- package/images/agents/workbuddy.svg +37 -37
- package/images/agents/zed.svg +1 -1
- package/lib/chat-import-compatibility.js +8 -5
- package/lib/client.js +36526 -4
- package/lib/contract.d.ts +4 -0
- package/lib/host.d.ts +2 -2
- package/lib/host.js +4 -1
- package/lib/local-conversation-reader.js +16 -11
- package/lib/render.js +21 -21
- package/lib/store/spec.d.ts +9 -1
- package/lib/tool.js +3 -2
- package/lib/wire.d.ts +13 -1
- package/lib/wire.js +5 -4
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/opencli-plugin/chatgpt.js +128 -128
- package/opencli-plugin/claude.js +138 -138
- package/opencli-plugin/common.js +452 -452
- package/opencli-plugin/deepseek.js +155 -155
- package/opencli-plugin/gemini.js +169 -169
- package/opencli-plugin/grok.js +124 -124
- package/opencli-plugin/kimi.js +126 -126
- package/opencli-plugin/opencli-plugin.json +6 -6
- package/opencli-plugin/package.json +8 -8
- package/opencli-plugin/since-guard.js +45 -45
- package/package.json +263 -262
- package/vendor/chat-import-reader/LICENSE +22 -22
- package/vendor/chat-import-reader/README.md +54 -48
- package/vendor/chat-import-reader/availability-worker.mjs +5 -0
- package/vendor/chat-import-reader/parsers.d.mts +6 -6
- package/vendor/chat-import-reader/parsers.mjs +2248 -347
- package/vendor/chat-import-reader/scanners.mjs +1316 -79
- package/vendor/chat-import-reader/upstream-sha256.json +35 -22
- package/release-notes/0.4.4/commands.png +0 -0
- package/release-notes/0.4.4/demos/agent-import.gif +0 -0
- package/release-notes/0.4.4/demos/chat-import-upgrade.gif +0 -0
- package/release-notes/0.4.4/demos/folders.gif +0 -0
- package/release-notes/0.4.4/demos/local-plugins.gif +0 -0
- package/release-notes/0.4.4/demos/shortcuts.gif +0 -0
- package/release-notes/0.4.4/files.png +0 -0
- package/release-notes/0.5.0/demos/agent-import.gif +0 -0
- package/release-notes/0.5.0/demos/chat-import-upgrade.gif +0 -0
- package/release-notes/0.5.0/demos/folders.gif +0 -0
- package/release-notes/0.5.0/demos/local-plugins.gif +0 -0
- package/release-notes/0.5.0/demos/local-references.gif +0 -0
- package/release-notes/0.5.0/demos/shortcuts.gif +0 -0
- /package/{release-notes → docs/release}/0.5.0/features.json +0 -0
package/opencli-plugin/common.js
CHANGED
|
@@ -1,452 +1,452 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
|
-
import { dirname, resolve } from 'node:path'
|
|
4
|
-
import { cli, Strategy } from '@jackwener/opencli/registry'
|
|
5
|
-
import { sinceGuardSource } from './since-guard.js'
|
|
6
|
-
import {
|
|
7
|
-
ArgumentError,
|
|
8
|
-
AuthRequiredError,
|
|
9
|
-
CommandExecutionError,
|
|
10
|
-
EmptyResultError,
|
|
11
|
-
} from '@jackwener/opencli/errors'
|
|
12
|
-
|
|
13
|
-
export { sinceGuardSource }
|
|
14
|
-
|
|
15
|
-
const HISTORY_COLUMNS = [
|
|
16
|
-
'provider', 'accountScope', 'id', 'title', 'url', 'createdAt', 'updatedAt',
|
|
17
|
-
'messageCount', 'cursor', 'partial',
|
|
18
|
-
]
|
|
19
|
-
|
|
20
|
-
const SYNC_INDEX_COLUMNS = ['kind', 'identity', 'sinceApplied', ...HISTORY_COLUMNS]
|
|
21
|
-
|
|
22
|
-
const DETAIL_COLUMNS = [
|
|
23
|
-
'conversationId', 'ordinal', 'messageId', 'parentId', 'branchId',
|
|
24
|
-
'activeBranch', 'role', 'text', 'createdAt', 'attachmentsJson', 'partial',
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
function parseEvaluate(value, operation) {
|
|
28
|
-
const raw = typeof value === 'string' ? value : JSON.stringify(value)
|
|
29
|
-
try {
|
|
30
|
-
return JSON.parse(raw)
|
|
31
|
-
} catch (error) {
|
|
32
|
-
throw new CommandExecutionError(`${operation}: provider page returned malformed JSON: ${String(error)}`)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function evaluate(page, script, args, operation) {
|
|
37
|
-
const source = `(() => { const __args = ${JSON.stringify(args)}; return (${script})(__args); })()`
|
|
38
|
-
return parseEvaluate(await page.evaluate(source), operation)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Refuse an identity that does not match the account scope captured by sync. */
|
|
42
|
-
export function verifyAccountScope(provider, site, expectedScope, identity) {
|
|
43
|
-
if (!expectedScope) return
|
|
44
|
-
const actualScope = createHash('sha256').update(`${provider.toLocaleLowerCase()}\0${identity}`).digest('hex')
|
|
45
|
-
if (actualScope !== expectedScope) {
|
|
46
|
-
throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${site} attachment: attachment belongs to a different logged-in account`)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Preserve omission compatibility while rejecting malformed supplied scopes. */
|
|
51
|
-
export function parseExpectedAccountScope(value) {
|
|
52
|
-
if (value === undefined || value === null) return ''
|
|
53
|
-
const scope = String(value).trim()
|
|
54
|
-
if (!/^[a-f0-9]{64}$/.test(scope)) throw new ArgumentError('accountScope must be a 64-character lowercase hexadecimal hash')
|
|
55
|
-
return scope
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function assertResult(result, domain, operation) {
|
|
59
|
-
if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message || `${operation} requires login`)
|
|
60
|
-
if (result?.code === 'RATE_LIMIT') {
|
|
61
|
-
throw new CommandExecutionError(`${operation}: ${result.message || 'provider rate limit reached'}`)
|
|
62
|
-
}
|
|
63
|
-
if (result?.ok !== true || !Array.isArray(result.rows)) {
|
|
64
|
-
throw new CommandExecutionError(`${operation}: ${result?.message || 'provider adapter did not return rows'}`)
|
|
65
|
-
}
|
|
66
|
-
return result.rows
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function assertIdentityResult(result, domain, operation) {
|
|
70
|
-
if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message)
|
|
71
|
-
if (result?.code === 'RATE_LIMIT') {
|
|
72
|
-
throw new CommandExecutionError(`DSH_PROVIDER_RATE_LIMIT: ${operation}: ${result.message || 'provider rate limit reached'}`)
|
|
73
|
-
}
|
|
74
|
-
if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
|
|
75
|
-
throw new CommandExecutionError(`${operation}: ${result?.message || 'stable account identity unavailable'}`)
|
|
76
|
-
}
|
|
77
|
-
return result.identity
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const PROVIDER_READY_TIMEOUT_MS = 10_000
|
|
81
|
-
const PROVIDER_READY_INITIAL_DELAY_MS = 50
|
|
82
|
-
const PROVIDER_READY_MAX_DELAY_MS = 400
|
|
83
|
-
|
|
84
|
-
function terminalProviderResult(result) {
|
|
85
|
-
return result?.ok === true || result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function terminalIdentityResult(result) {
|
|
89
|
-
return result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
|
|
90
|
-
|| (result?.ok === true && typeof result.identity === 'string' && result.identity.length > 0)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Run the provider API as the readiness probe. The happy path performs one
|
|
95
|
-
* evaluate immediately after navigation; transient bootstrap failures retry
|
|
96
|
-
* with bounded exponential backoff instead of waiting for the whole SPA DOM
|
|
97
|
-
* to become quiet first.
|
|
98
|
-
*/
|
|
99
|
-
export async function evaluateWhenProviderReady(page, script, args, operation, options = {}) {
|
|
100
|
-
const timeoutMs = options.timeoutMs ?? PROVIDER_READY_TIMEOUT_MS
|
|
101
|
-
const maxDelayMs = options.maxDelayMs ?? PROVIDER_READY_MAX_DELAY_MS
|
|
102
|
-
const accepts = options.accepts ?? terminalProviderResult
|
|
103
|
-
const deadline = Date.now() + timeoutMs
|
|
104
|
-
let delayMs = options.initialDelayMs ?? PROVIDER_READY_INITIAL_DELAY_MS
|
|
105
|
-
let lastResult
|
|
106
|
-
let lastError
|
|
107
|
-
for (;;) {
|
|
108
|
-
try {
|
|
109
|
-
lastResult = await evaluate(page, script, args, operation)
|
|
110
|
-
lastError = undefined
|
|
111
|
-
if (accepts(lastResult)) return lastResult
|
|
112
|
-
} catch (error) {
|
|
113
|
-
lastError = error
|
|
114
|
-
}
|
|
115
|
-
const remaining = deadline - Date.now()
|
|
116
|
-
if (remaining <= 0) {
|
|
117
|
-
if (lastError) throw lastError
|
|
118
|
-
return lastResult
|
|
119
|
-
}
|
|
120
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining)))
|
|
121
|
-
delayMs = Math.min(Math.max(delayMs * 2, 1), maxDelayMs)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Each command owns an ephemeral Browser Bridge tab. Close that tab after the
|
|
127
|
-
* command finishes while preserving the provider result or failure.
|
|
128
|
-
*/
|
|
129
|
-
async function inTemporaryTab(page, task) {
|
|
130
|
-
try {
|
|
131
|
-
return await task()
|
|
132
|
-
} finally {
|
|
133
|
-
try {
|
|
134
|
-
await page.closeTab?.()
|
|
135
|
-
} catch {
|
|
136
|
-
// Preserve the provider result/error if the tab or browser closed first.
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function navigate(page, url, options) {
|
|
142
|
-
let last
|
|
143
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
144
|
-
try { await page.goto(url, options); return } catch (error) {
|
|
145
|
-
last = error
|
|
146
|
-
if (!/Navigation rejected/i.test(String(error?.message || error)) || attempt === 2) throw error
|
|
147
|
-
if (attempt > 0 && typeof page.newTab === 'function' && typeof page.setActivePage === 'function') {
|
|
148
|
-
const pageId = await page.newTab(url).catch(() => undefined)
|
|
149
|
-
if (pageId) { await page.setActivePage(pageId); return }
|
|
150
|
-
}
|
|
151
|
-
await new Promise(resolve => setTimeout(resolve, 250 * (attempt + 1)))
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
throw last
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function registerProvider(config) {
|
|
158
|
-
const browserSession = SYNC_BROWSER_SESSION
|
|
159
|
-
|
|
160
|
-
cli({
|
|
161
|
-
site: config.site,
|
|
162
|
-
name: 'whoami',
|
|
163
|
-
access: 'read',
|
|
164
|
-
description: `Resolve the stable ${config.provider} account identity for local scoping`,
|
|
165
|
-
domain: config.domain,
|
|
166
|
-
strategy: Strategy.COOKIE,
|
|
167
|
-
browser: true,
|
|
168
|
-
...browserSession,
|
|
169
|
-
navigateBefore: false,
|
|
170
|
-
args: [],
|
|
171
|
-
columns: ['identity'],
|
|
172
|
-
func: async (page) => inTemporaryTab(page, async () => {
|
|
173
|
-
await navigate(page, config.home, { waitUntil: 'none' })
|
|
174
|
-
const result = await evaluateWhenProviderReady(
|
|
175
|
-
page, config.whoamiScript, {}, `${config.site} whoami`, { accepts: terminalIdentityResult },
|
|
176
|
-
)
|
|
177
|
-
return [{ identity: assertIdentityResult(result, config.domain, `${config.site} whoami`) }]
|
|
178
|
-
}),
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
cli({
|
|
182
|
-
site: config.site,
|
|
183
|
-
name: 'sync-index',
|
|
184
|
-
access: 'read',
|
|
185
|
-
description: `Resolve the ${config.provider} account and list its conversation history in one browser session`,
|
|
186
|
-
domain: config.domain,
|
|
187
|
-
strategy: Strategy.COOKIE,
|
|
188
|
-
browser: true,
|
|
189
|
-
...browserSession,
|
|
190
|
-
navigateBefore: false,
|
|
191
|
-
args: [{
|
|
192
|
-
name: 'since',
|
|
193
|
-
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
194
|
-
}, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
|
|
195
|
-
columns: SYNC_INDEX_COLUMNS,
|
|
196
|
-
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
197
|
-
const since = String(kwargs.since || '').trim()
|
|
198
|
-
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
199
|
-
await navigate(page, config.home, { waitUntil: 'none' })
|
|
200
|
-
const identity = await evaluateWhenProviderReady(
|
|
201
|
-
page, config.whoamiScript, {}, `${config.site} sync-index identity`, { accepts: terminalIdentityResult },
|
|
202
|
-
)
|
|
203
|
-
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} sync-index`)
|
|
204
|
-
const accountScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
|
|
205
|
-
const effectiveSince = String(kwargs.accountScope || '') === accountScope ? since : ''
|
|
206
|
-
const rows = assertResult(
|
|
207
|
-
await evaluate(page, config.historyScript, { since: effectiveSince }, `${config.site} sync-index history`),
|
|
208
|
-
config.domain,
|
|
209
|
-
`${config.site} sync-index`,
|
|
210
|
-
)
|
|
211
|
-
return [
|
|
212
|
-
{ kind: 'identity', identity: stableIdentity, sinceApplied: effectiveSince },
|
|
213
|
-
...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
|
|
214
|
-
]
|
|
215
|
-
}),
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
cli({
|
|
219
|
-
site: config.site,
|
|
220
|
-
name: 'history-all',
|
|
221
|
-
access: 'read',
|
|
222
|
-
description: `List the ${config.provider} web conversation history, whole or since an instant`,
|
|
223
|
-
domain: config.domain,
|
|
224
|
-
strategy: Strategy.COOKIE,
|
|
225
|
-
browser: true,
|
|
226
|
-
...browserSession,
|
|
227
|
-
navigateBefore: false,
|
|
228
|
-
args: [{
|
|
229
|
-
name: 'since',
|
|
230
|
-
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
231
|
-
}],
|
|
232
|
-
columns: HISTORY_COLUMNS,
|
|
233
|
-
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
234
|
-
const since = String(kwargs.since || '').trim()
|
|
235
|
-
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
236
|
-
await navigate(page, config.home, { waitUntil: 'none' })
|
|
237
|
-
const rows = assertResult(
|
|
238
|
-
await evaluateWhenProviderReady(page, config.historyScript, { since }, `${config.site} history-all`),
|
|
239
|
-
config.domain,
|
|
240
|
-
`${config.site} history-all`,
|
|
241
|
-
)
|
|
242
|
-
return rows
|
|
243
|
-
}),
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
cli({
|
|
247
|
-
site: config.site,
|
|
248
|
-
name: 'detail',
|
|
249
|
-
access: 'read',
|
|
250
|
-
description: `Read one complete ${config.provider} conversation`,
|
|
251
|
-
domain: config.domain,
|
|
252
|
-
strategy: Strategy.COOKIE,
|
|
253
|
-
browser: true,
|
|
254
|
-
...browserSession,
|
|
255
|
-
navigateBefore: false,
|
|
256
|
-
args: [
|
|
257
|
-
{ name: 'id', positional: true, required: true, help: 'Provider conversation id' },
|
|
258
|
-
{ name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
|
|
259
|
-
],
|
|
260
|
-
columns: DETAIL_COLUMNS,
|
|
261
|
-
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
262
|
-
const id = String(kwargs.id || '').trim()
|
|
263
|
-
if (!id) throw new ArgumentError('id must be a non-empty conversation id')
|
|
264
|
-
await navigate(page, config.home, { settleMs: 600 })
|
|
265
|
-
const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
|
|
266
|
-
if (expectedScope) {
|
|
267
|
-
const identity = await evaluateWhenProviderReady(
|
|
268
|
-
page, config.whoamiScript, {}, `${config.site} detail identity`, { accepts: terminalIdentityResult },
|
|
269
|
-
)
|
|
270
|
-
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} detail`)
|
|
271
|
-
const actualScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
|
|
272
|
-
if (actualScope !== expectedScope) {
|
|
273
|
-
throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${config.site} detail: conversation belongs to a different logged-in account`)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
let result = await evaluateWhenProviderReady(page, config.detailScript, { id }, `${config.site} detail`)
|
|
277
|
-
if (result?.ok !== true && result?.code !== 'AUTH' && result?.code !== 'RATE_LIMIT' && config.fallbackScript) {
|
|
278
|
-
await navigate(page, config.conversationUrl(id), { settleMs: 1800 })
|
|
279
|
-
result = await evaluate(page, config.fallbackScript, { id }, `${config.site} detail fallback`)
|
|
280
|
-
}
|
|
281
|
-
const rows = assertResult(result, config.domain, `${config.site} detail`)
|
|
282
|
-
if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
|
|
283
|
-
return rows
|
|
284
|
-
}),
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
cli({
|
|
288
|
-
site: config.site,
|
|
289
|
-
name: 'attachment',
|
|
290
|
-
access: 'read',
|
|
291
|
-
description: `Materialize one authorized ${config.provider} attachment`,
|
|
292
|
-
domain: config.domain,
|
|
293
|
-
strategy: Strategy.COOKIE,
|
|
294
|
-
browser: true,
|
|
295
|
-
...browserSession,
|
|
296
|
-
navigateBefore: false,
|
|
297
|
-
args: [
|
|
298
|
-
{ name: 'locator', positional: true, required: true, help: 'Stable same-origin attachment locator' },
|
|
299
|
-
{ name: 'output', required: true, help: 'Absolute output file path' },
|
|
300
|
-
{ name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
|
|
301
|
-
{ name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the download' },
|
|
302
|
-
],
|
|
303
|
-
columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
|
|
304
|
-
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
305
|
-
const locator = String(kwargs.locator || '').trim()
|
|
306
|
-
const output = resolve(String(kwargs.output || ''))
|
|
307
|
-
const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
|
|
308
|
-
if (!locator) throw new ArgumentError('locator must not be empty')
|
|
309
|
-
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 26_214_400) {
|
|
310
|
-
throw new ArgumentError('maxBytes must be between 1 and 26214400')
|
|
311
|
-
}
|
|
312
|
-
await navigate(page, config.home, { settleMs: 500 })
|
|
313
|
-
const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
|
|
314
|
-
if (expectedScope) {
|
|
315
|
-
const identity = await evaluateWhenProviderReady(
|
|
316
|
-
page, config.whoamiScript, {}, `${config.site} attachment identity`, { accepts: terminalIdentityResult },
|
|
317
|
-
)
|
|
318
|
-
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} attachment`)
|
|
319
|
-
verifyAccountScope(config.provider, config.site, expectedScope, stableIdentity)
|
|
320
|
-
}
|
|
321
|
-
const result = await evaluate(page, ATTACHMENT_SCRIPT, {
|
|
322
|
-
locator,
|
|
323
|
-
origin: new URL(config.home).origin,
|
|
324
|
-
maxBytes,
|
|
325
|
-
}, `${config.site} attachment`)
|
|
326
|
-
if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
327
|
-
if (result?.code === 'TOO_LARGE') {
|
|
328
|
-
throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
|
|
329
|
-
}
|
|
330
|
-
if (result?.ok !== true || typeof result.base64 !== 'string') {
|
|
331
|
-
throw new CommandExecutionError(`${config.site} attachment: ${result?.message || 'attachment unavailable'}`)
|
|
332
|
-
}
|
|
333
|
-
const bytes = Buffer.from(result.base64, 'base64')
|
|
334
|
-
if (bytes.byteLength > maxBytes) {
|
|
335
|
-
throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
|
|
336
|
-
}
|
|
337
|
-
await mkdir(dirname(output), { recursive: true })
|
|
338
|
-
await writeFile(output, bytes)
|
|
339
|
-
return [{
|
|
340
|
-
attachmentId: locator,
|
|
341
|
-
name: result.name || output.split(/[\\/]/).pop() || 'attachment',
|
|
342
|
-
mimeType: result.mimeType || 'application/octet-stream',
|
|
343
|
-
size: bytes.byteLength,
|
|
344
|
-
status: 'available',
|
|
345
|
-
localPath: output,
|
|
346
|
-
}]
|
|
347
|
-
}),
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** Browser Bridge lifecycle shared by every web-chat command. */
|
|
352
|
-
export const SYNC_BROWSER_SESSION = Object.freeze({
|
|
353
|
-
siteSession: 'ephemeral',
|
|
354
|
-
defaultWindowMode: 'background',
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
export const ATTACHMENT_SCRIPT = String.raw`async function (args) {
|
|
358
|
-
try {
|
|
359
|
-
const cancelBody = async body => {
|
|
360
|
-
try { await body?.cancel?.() } catch {}
|
|
361
|
-
}
|
|
362
|
-
const target = new URL(args.locator, args.origin)
|
|
363
|
-
if (target.origin !== args.origin) return JSON.stringify({ ok: false, message: 'cross-origin locator refused' })
|
|
364
|
-
const response = await fetch(target.href, { credentials: 'include', redirect: 'error' })
|
|
365
|
-
if (response.url && new URL(response.url).origin !== args.origin) {
|
|
366
|
-
await cancelBody(response.body)
|
|
367
|
-
return JSON.stringify({ ok: false, message: 'cross-origin redirect refused' })
|
|
368
|
-
}
|
|
369
|
-
if (response.status === 401 || response.status === 403) {
|
|
370
|
-
await cancelBody(response.body)
|
|
371
|
-
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
372
|
-
}
|
|
373
|
-
if (!response.ok) {
|
|
374
|
-
await cancelBody(response.body)
|
|
375
|
-
return JSON.stringify({ ok: false, message: 'attachment HTTP ' + response.status })
|
|
376
|
-
}
|
|
377
|
-
const length = Number(response.headers.get('content-length') || 0)
|
|
378
|
-
const declaredLength = Number.isFinite(length) && length > 0 ? length : 0
|
|
379
|
-
if (declaredLength > args.maxBytes) {
|
|
380
|
-
await cancelBody(response.body)
|
|
381
|
-
return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
|
|
382
|
-
}
|
|
383
|
-
const chunks = []
|
|
384
|
-
let total = 0
|
|
385
|
-
if (response.body) {
|
|
386
|
-
const reader = response.body.getReader()
|
|
387
|
-
try {
|
|
388
|
-
for (;;) {
|
|
389
|
-
const { done, value } = await reader.read()
|
|
390
|
-
if (done) break
|
|
391
|
-
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
|
|
392
|
-
if (total + chunk.byteLength > args.maxBytes) {
|
|
393
|
-
try { await reader.cancel() } catch {}
|
|
394
|
-
return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
|
|
395
|
-
}
|
|
396
|
-
chunks.push(chunk)
|
|
397
|
-
total += chunk.byteLength
|
|
398
|
-
}
|
|
399
|
-
} catch (error) {
|
|
400
|
-
try { await reader.cancel() } catch {}
|
|
401
|
-
throw error
|
|
402
|
-
} finally {
|
|
403
|
-
try { reader.releaseLock?.() } catch {}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (declaredLength && total < declaredLength) {
|
|
407
|
-
return JSON.stringify({ ok: false, message: 'attachment download was truncated' })
|
|
408
|
-
}
|
|
409
|
-
const bytes = new Uint8Array(total)
|
|
410
|
-
let offset = 0
|
|
411
|
-
for (const chunk of chunks) {
|
|
412
|
-
bytes.set(chunk, offset)
|
|
413
|
-
offset += chunk.byteLength
|
|
414
|
-
}
|
|
415
|
-
let binary = ''
|
|
416
|
-
for (let at = 0; at < bytes.length; at += 0x8000) {
|
|
417
|
-
binary += String.fromCharCode(...bytes.subarray(at, Math.min(bytes.length, at + 0x8000)))
|
|
418
|
-
}
|
|
419
|
-
return JSON.stringify({
|
|
420
|
-
ok: true,
|
|
421
|
-
base64: btoa(binary),
|
|
422
|
-
mimeType: response.headers.get('content-type') || 'application/octet-stream',
|
|
423
|
-
name: target.pathname.split('/').pop() || 'attachment',
|
|
424
|
-
})
|
|
425
|
-
} catch (error) {
|
|
426
|
-
return JSON.stringify({ ok: false, message: String(error && error.message || error) })
|
|
427
|
-
}
|
|
428
|
-
}`
|
|
429
|
-
|
|
430
|
-
export const domFallbackScript = String.raw`async function (args) {
|
|
431
|
-
const nodes = Array.from(document.querySelectorAll('[data-message-author-role], user-query, model-response, [class*="message"]'))
|
|
432
|
-
const rows = []
|
|
433
|
-
let ordinal = 0
|
|
434
|
-
for (const node of nodes) {
|
|
435
|
-
if (node.parentElement && node.parentElement.closest('[data-message-author-role], user-query, model-response') !== node) continue
|
|
436
|
-
const attr = (node.getAttribute('data-message-author-role') || '').toLowerCase()
|
|
437
|
-
const tag = node.tagName.toLowerCase()
|
|
438
|
-
const role = attr === 'user' || tag === 'user-query' || /user|human/i.test(node.className || '') ? 'user'
|
|
439
|
-
: attr === 'assistant' || tag === 'model-response' || /assistant|model-response|claude/i.test(node.className || '') ? 'assistant'
|
|
440
|
-
: null
|
|
441
|
-
const text = (node.innerText || node.textContent || '').trim()
|
|
442
|
-
if (!role || !text) continue
|
|
443
|
-
rows.push({
|
|
444
|
-
conversationId: args.id,
|
|
445
|
-
ordinal: ordinal++,
|
|
446
|
-
messageId: node.getAttribute('data-message-id') || '',
|
|
447
|
-
parentId: '', branchId: '', activeBranch: true,
|
|
448
|
-
role, text, createdAt: '', attachmentsJson: '[]', partial: true,
|
|
449
|
-
})
|
|
450
|
-
}
|
|
451
|
-
return JSON.stringify({ ok: rows.length > 0, rows, message: rows.length ? '' : 'DOM fallback found no messages' })
|
|
452
|
-
}`
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry'
|
|
5
|
+
import { sinceGuardSource } from './since-guard.js'
|
|
6
|
+
import {
|
|
7
|
+
ArgumentError,
|
|
8
|
+
AuthRequiredError,
|
|
9
|
+
CommandExecutionError,
|
|
10
|
+
EmptyResultError,
|
|
11
|
+
} from '@jackwener/opencli/errors'
|
|
12
|
+
|
|
13
|
+
export { sinceGuardSource }
|
|
14
|
+
|
|
15
|
+
const HISTORY_COLUMNS = [
|
|
16
|
+
'provider', 'accountScope', 'id', 'title', 'url', 'createdAt', 'updatedAt',
|
|
17
|
+
'messageCount', 'cursor', 'partial',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const SYNC_INDEX_COLUMNS = ['kind', 'identity', 'sinceApplied', ...HISTORY_COLUMNS]
|
|
21
|
+
|
|
22
|
+
const DETAIL_COLUMNS = [
|
|
23
|
+
'conversationId', 'ordinal', 'messageId', 'parentId', 'branchId',
|
|
24
|
+
'activeBranch', 'role', 'text', 'createdAt', 'attachmentsJson', 'partial',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function parseEvaluate(value, operation) {
|
|
28
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(value)
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new CommandExecutionError(`${operation}: provider page returned malformed JSON: ${String(error)}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function evaluate(page, script, args, operation) {
|
|
37
|
+
const source = `(() => { const __args = ${JSON.stringify(args)}; return (${script})(__args); })()`
|
|
38
|
+
return parseEvaluate(await page.evaluate(source), operation)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Refuse an identity that does not match the account scope captured by sync. */
|
|
42
|
+
export function verifyAccountScope(provider, site, expectedScope, identity) {
|
|
43
|
+
if (!expectedScope) return
|
|
44
|
+
const actualScope = createHash('sha256').update(`${provider.toLocaleLowerCase()}\0${identity}`).digest('hex')
|
|
45
|
+
if (actualScope !== expectedScope) {
|
|
46
|
+
throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${site} attachment: attachment belongs to a different logged-in account`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Preserve omission compatibility while rejecting malformed supplied scopes. */
|
|
51
|
+
export function parseExpectedAccountScope(value) {
|
|
52
|
+
if (value === undefined || value === null) return ''
|
|
53
|
+
const scope = String(value).trim()
|
|
54
|
+
if (!/^[a-f0-9]{64}$/.test(scope)) throw new ArgumentError('accountScope must be a 64-character lowercase hexadecimal hash')
|
|
55
|
+
return scope
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertResult(result, domain, operation) {
|
|
59
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message || `${operation} requires login`)
|
|
60
|
+
if (result?.code === 'RATE_LIMIT') {
|
|
61
|
+
throw new CommandExecutionError(`${operation}: ${result.message || 'provider rate limit reached'}`)
|
|
62
|
+
}
|
|
63
|
+
if (result?.ok !== true || !Array.isArray(result.rows)) {
|
|
64
|
+
throw new CommandExecutionError(`${operation}: ${result?.message || 'provider adapter did not return rows'}`)
|
|
65
|
+
}
|
|
66
|
+
return result.rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertIdentityResult(result, domain, operation) {
|
|
70
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message)
|
|
71
|
+
if (result?.code === 'RATE_LIMIT') {
|
|
72
|
+
throw new CommandExecutionError(`DSH_PROVIDER_RATE_LIMIT: ${operation}: ${result.message || 'provider rate limit reached'}`)
|
|
73
|
+
}
|
|
74
|
+
if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
|
|
75
|
+
throw new CommandExecutionError(`${operation}: ${result?.message || 'stable account identity unavailable'}`)
|
|
76
|
+
}
|
|
77
|
+
return result.identity
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PROVIDER_READY_TIMEOUT_MS = 10_000
|
|
81
|
+
const PROVIDER_READY_INITIAL_DELAY_MS = 50
|
|
82
|
+
const PROVIDER_READY_MAX_DELAY_MS = 400
|
|
83
|
+
|
|
84
|
+
function terminalProviderResult(result) {
|
|
85
|
+
return result?.ok === true || result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function terminalIdentityResult(result) {
|
|
89
|
+
return result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
|
|
90
|
+
|| (result?.ok === true && typeof result.identity === 'string' && result.identity.length > 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run the provider API as the readiness probe. The happy path performs one
|
|
95
|
+
* evaluate immediately after navigation; transient bootstrap failures retry
|
|
96
|
+
* with bounded exponential backoff instead of waiting for the whole SPA DOM
|
|
97
|
+
* to become quiet first.
|
|
98
|
+
*/
|
|
99
|
+
export async function evaluateWhenProviderReady(page, script, args, operation, options = {}) {
|
|
100
|
+
const timeoutMs = options.timeoutMs ?? PROVIDER_READY_TIMEOUT_MS
|
|
101
|
+
const maxDelayMs = options.maxDelayMs ?? PROVIDER_READY_MAX_DELAY_MS
|
|
102
|
+
const accepts = options.accepts ?? terminalProviderResult
|
|
103
|
+
const deadline = Date.now() + timeoutMs
|
|
104
|
+
let delayMs = options.initialDelayMs ?? PROVIDER_READY_INITIAL_DELAY_MS
|
|
105
|
+
let lastResult
|
|
106
|
+
let lastError
|
|
107
|
+
for (;;) {
|
|
108
|
+
try {
|
|
109
|
+
lastResult = await evaluate(page, script, args, operation)
|
|
110
|
+
lastError = undefined
|
|
111
|
+
if (accepts(lastResult)) return lastResult
|
|
112
|
+
} catch (error) {
|
|
113
|
+
lastError = error
|
|
114
|
+
}
|
|
115
|
+
const remaining = deadline - Date.now()
|
|
116
|
+
if (remaining <= 0) {
|
|
117
|
+
if (lastError) throw lastError
|
|
118
|
+
return lastResult
|
|
119
|
+
}
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining)))
|
|
121
|
+
delayMs = Math.min(Math.max(delayMs * 2, 1), maxDelayMs)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Each command owns an ephemeral Browser Bridge tab. Close that tab after the
|
|
127
|
+
* command finishes while preserving the provider result or failure.
|
|
128
|
+
*/
|
|
129
|
+
async function inTemporaryTab(page, task) {
|
|
130
|
+
try {
|
|
131
|
+
return await task()
|
|
132
|
+
} finally {
|
|
133
|
+
try {
|
|
134
|
+
await page.closeTab?.()
|
|
135
|
+
} catch {
|
|
136
|
+
// Preserve the provider result/error if the tab or browser closed first.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function navigate(page, url, options) {
|
|
142
|
+
let last
|
|
143
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
144
|
+
try { await page.goto(url, options); return } catch (error) {
|
|
145
|
+
last = error
|
|
146
|
+
if (!/Navigation rejected/i.test(String(error?.message || error)) || attempt === 2) throw error
|
|
147
|
+
if (attempt > 0 && typeof page.newTab === 'function' && typeof page.setActivePage === 'function') {
|
|
148
|
+
const pageId = await page.newTab(url).catch(() => undefined)
|
|
149
|
+
if (pageId) { await page.setActivePage(pageId); return }
|
|
150
|
+
}
|
|
151
|
+
await new Promise(resolve => setTimeout(resolve, 250 * (attempt + 1)))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw last
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function registerProvider(config) {
|
|
158
|
+
const browserSession = SYNC_BROWSER_SESSION
|
|
159
|
+
|
|
160
|
+
cli({
|
|
161
|
+
site: config.site,
|
|
162
|
+
name: 'whoami',
|
|
163
|
+
access: 'read',
|
|
164
|
+
description: `Resolve the stable ${config.provider} account identity for local scoping`,
|
|
165
|
+
domain: config.domain,
|
|
166
|
+
strategy: Strategy.COOKIE,
|
|
167
|
+
browser: true,
|
|
168
|
+
...browserSession,
|
|
169
|
+
navigateBefore: false,
|
|
170
|
+
args: [],
|
|
171
|
+
columns: ['identity'],
|
|
172
|
+
func: async (page) => inTemporaryTab(page, async () => {
|
|
173
|
+
await navigate(page, config.home, { waitUntil: 'none' })
|
|
174
|
+
const result = await evaluateWhenProviderReady(
|
|
175
|
+
page, config.whoamiScript, {}, `${config.site} whoami`, { accepts: terminalIdentityResult },
|
|
176
|
+
)
|
|
177
|
+
return [{ identity: assertIdentityResult(result, config.domain, `${config.site} whoami`) }]
|
|
178
|
+
}),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
cli({
|
|
182
|
+
site: config.site,
|
|
183
|
+
name: 'sync-index',
|
|
184
|
+
access: 'read',
|
|
185
|
+
description: `Resolve the ${config.provider} account and list its conversation history in one browser session`,
|
|
186
|
+
domain: config.domain,
|
|
187
|
+
strategy: Strategy.COOKIE,
|
|
188
|
+
browser: true,
|
|
189
|
+
...browserSession,
|
|
190
|
+
navigateBefore: false,
|
|
191
|
+
args: [{
|
|
192
|
+
name: 'since',
|
|
193
|
+
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
194
|
+
}, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
|
|
195
|
+
columns: SYNC_INDEX_COLUMNS,
|
|
196
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
197
|
+
const since = String(kwargs.since || '').trim()
|
|
198
|
+
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
199
|
+
await navigate(page, config.home, { waitUntil: 'none' })
|
|
200
|
+
const identity = await evaluateWhenProviderReady(
|
|
201
|
+
page, config.whoamiScript, {}, `${config.site} sync-index identity`, { accepts: terminalIdentityResult },
|
|
202
|
+
)
|
|
203
|
+
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} sync-index`)
|
|
204
|
+
const accountScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
|
|
205
|
+
const effectiveSince = String(kwargs.accountScope || '') === accountScope ? since : ''
|
|
206
|
+
const rows = assertResult(
|
|
207
|
+
await evaluate(page, config.historyScript, { since: effectiveSince }, `${config.site} sync-index history`),
|
|
208
|
+
config.domain,
|
|
209
|
+
`${config.site} sync-index`,
|
|
210
|
+
)
|
|
211
|
+
return [
|
|
212
|
+
{ kind: 'identity', identity: stableIdentity, sinceApplied: effectiveSince },
|
|
213
|
+
...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
|
|
214
|
+
]
|
|
215
|
+
}),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
cli({
|
|
219
|
+
site: config.site,
|
|
220
|
+
name: 'history-all',
|
|
221
|
+
access: 'read',
|
|
222
|
+
description: `List the ${config.provider} web conversation history, whole or since an instant`,
|
|
223
|
+
domain: config.domain,
|
|
224
|
+
strategy: Strategy.COOKIE,
|
|
225
|
+
browser: true,
|
|
226
|
+
...browserSession,
|
|
227
|
+
navigateBefore: false,
|
|
228
|
+
args: [{
|
|
229
|
+
name: 'since',
|
|
230
|
+
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
231
|
+
}],
|
|
232
|
+
columns: HISTORY_COLUMNS,
|
|
233
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
234
|
+
const since = String(kwargs.since || '').trim()
|
|
235
|
+
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
236
|
+
await navigate(page, config.home, { waitUntil: 'none' })
|
|
237
|
+
const rows = assertResult(
|
|
238
|
+
await evaluateWhenProviderReady(page, config.historyScript, { since }, `${config.site} history-all`),
|
|
239
|
+
config.domain,
|
|
240
|
+
`${config.site} history-all`,
|
|
241
|
+
)
|
|
242
|
+
return rows
|
|
243
|
+
}),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
cli({
|
|
247
|
+
site: config.site,
|
|
248
|
+
name: 'detail',
|
|
249
|
+
access: 'read',
|
|
250
|
+
description: `Read one complete ${config.provider} conversation`,
|
|
251
|
+
domain: config.domain,
|
|
252
|
+
strategy: Strategy.COOKIE,
|
|
253
|
+
browser: true,
|
|
254
|
+
...browserSession,
|
|
255
|
+
navigateBefore: false,
|
|
256
|
+
args: [
|
|
257
|
+
{ name: 'id', positional: true, required: true, help: 'Provider conversation id' },
|
|
258
|
+
{ name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
|
|
259
|
+
],
|
|
260
|
+
columns: DETAIL_COLUMNS,
|
|
261
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
262
|
+
const id = String(kwargs.id || '').trim()
|
|
263
|
+
if (!id) throw new ArgumentError('id must be a non-empty conversation id')
|
|
264
|
+
await navigate(page, config.home, { settleMs: 600 })
|
|
265
|
+
const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
|
|
266
|
+
if (expectedScope) {
|
|
267
|
+
const identity = await evaluateWhenProviderReady(
|
|
268
|
+
page, config.whoamiScript, {}, `${config.site} detail identity`, { accepts: terminalIdentityResult },
|
|
269
|
+
)
|
|
270
|
+
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} detail`)
|
|
271
|
+
const actualScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
|
|
272
|
+
if (actualScope !== expectedScope) {
|
|
273
|
+
throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${config.site} detail: conversation belongs to a different logged-in account`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let result = await evaluateWhenProviderReady(page, config.detailScript, { id }, `${config.site} detail`)
|
|
277
|
+
if (result?.ok !== true && result?.code !== 'AUTH' && result?.code !== 'RATE_LIMIT' && config.fallbackScript) {
|
|
278
|
+
await navigate(page, config.conversationUrl(id), { settleMs: 1800 })
|
|
279
|
+
result = await evaluate(page, config.fallbackScript, { id }, `${config.site} detail fallback`)
|
|
280
|
+
}
|
|
281
|
+
const rows = assertResult(result, config.domain, `${config.site} detail`)
|
|
282
|
+
if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
|
|
283
|
+
return rows
|
|
284
|
+
}),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
cli({
|
|
288
|
+
site: config.site,
|
|
289
|
+
name: 'attachment',
|
|
290
|
+
access: 'read',
|
|
291
|
+
description: `Materialize one authorized ${config.provider} attachment`,
|
|
292
|
+
domain: config.domain,
|
|
293
|
+
strategy: Strategy.COOKIE,
|
|
294
|
+
browser: true,
|
|
295
|
+
...browserSession,
|
|
296
|
+
navigateBefore: false,
|
|
297
|
+
args: [
|
|
298
|
+
{ name: 'locator', positional: true, required: true, help: 'Stable same-origin attachment locator' },
|
|
299
|
+
{ name: 'output', required: true, help: 'Absolute output file path' },
|
|
300
|
+
{ name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
|
|
301
|
+
{ name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the download' },
|
|
302
|
+
],
|
|
303
|
+
columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
|
|
304
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
305
|
+
const locator = String(kwargs.locator || '').trim()
|
|
306
|
+
const output = resolve(String(kwargs.output || ''))
|
|
307
|
+
const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
|
|
308
|
+
if (!locator) throw new ArgumentError('locator must not be empty')
|
|
309
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 26_214_400) {
|
|
310
|
+
throw new ArgumentError('maxBytes must be between 1 and 26214400')
|
|
311
|
+
}
|
|
312
|
+
await navigate(page, config.home, { settleMs: 500 })
|
|
313
|
+
const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
|
|
314
|
+
if (expectedScope) {
|
|
315
|
+
const identity = await evaluateWhenProviderReady(
|
|
316
|
+
page, config.whoamiScript, {}, `${config.site} attachment identity`, { accepts: terminalIdentityResult },
|
|
317
|
+
)
|
|
318
|
+
const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} attachment`)
|
|
319
|
+
verifyAccountScope(config.provider, config.site, expectedScope, stableIdentity)
|
|
320
|
+
}
|
|
321
|
+
const result = await evaluate(page, ATTACHMENT_SCRIPT, {
|
|
322
|
+
locator,
|
|
323
|
+
origin: new URL(config.home).origin,
|
|
324
|
+
maxBytes,
|
|
325
|
+
}, `${config.site} attachment`)
|
|
326
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
327
|
+
if (result?.code === 'TOO_LARGE') {
|
|
328
|
+
throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
|
|
329
|
+
}
|
|
330
|
+
if (result?.ok !== true || typeof result.base64 !== 'string') {
|
|
331
|
+
throw new CommandExecutionError(`${config.site} attachment: ${result?.message || 'attachment unavailable'}`)
|
|
332
|
+
}
|
|
333
|
+
const bytes = Buffer.from(result.base64, 'base64')
|
|
334
|
+
if (bytes.byteLength > maxBytes) {
|
|
335
|
+
throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
|
|
336
|
+
}
|
|
337
|
+
await mkdir(dirname(output), { recursive: true })
|
|
338
|
+
await writeFile(output, bytes)
|
|
339
|
+
return [{
|
|
340
|
+
attachmentId: locator,
|
|
341
|
+
name: result.name || output.split(/[\\/]/).pop() || 'attachment',
|
|
342
|
+
mimeType: result.mimeType || 'application/octet-stream',
|
|
343
|
+
size: bytes.byteLength,
|
|
344
|
+
status: 'available',
|
|
345
|
+
localPath: output,
|
|
346
|
+
}]
|
|
347
|
+
}),
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Browser Bridge lifecycle shared by every web-chat command. */
|
|
352
|
+
export const SYNC_BROWSER_SESSION = Object.freeze({
|
|
353
|
+
siteSession: 'ephemeral',
|
|
354
|
+
defaultWindowMode: 'background',
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
export const ATTACHMENT_SCRIPT = String.raw`async function (args) {
|
|
358
|
+
try {
|
|
359
|
+
const cancelBody = async body => {
|
|
360
|
+
try { await body?.cancel?.() } catch {}
|
|
361
|
+
}
|
|
362
|
+
const target = new URL(args.locator, args.origin)
|
|
363
|
+
if (target.origin !== args.origin) return JSON.stringify({ ok: false, message: 'cross-origin locator refused' })
|
|
364
|
+
const response = await fetch(target.href, { credentials: 'include', redirect: 'error' })
|
|
365
|
+
if (response.url && new URL(response.url).origin !== args.origin) {
|
|
366
|
+
await cancelBody(response.body)
|
|
367
|
+
return JSON.stringify({ ok: false, message: 'cross-origin redirect refused' })
|
|
368
|
+
}
|
|
369
|
+
if (response.status === 401 || response.status === 403) {
|
|
370
|
+
await cancelBody(response.body)
|
|
371
|
+
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
372
|
+
}
|
|
373
|
+
if (!response.ok) {
|
|
374
|
+
await cancelBody(response.body)
|
|
375
|
+
return JSON.stringify({ ok: false, message: 'attachment HTTP ' + response.status })
|
|
376
|
+
}
|
|
377
|
+
const length = Number(response.headers.get('content-length') || 0)
|
|
378
|
+
const declaredLength = Number.isFinite(length) && length > 0 ? length : 0
|
|
379
|
+
if (declaredLength > args.maxBytes) {
|
|
380
|
+
await cancelBody(response.body)
|
|
381
|
+
return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
|
|
382
|
+
}
|
|
383
|
+
const chunks = []
|
|
384
|
+
let total = 0
|
|
385
|
+
if (response.body) {
|
|
386
|
+
const reader = response.body.getReader()
|
|
387
|
+
try {
|
|
388
|
+
for (;;) {
|
|
389
|
+
const { done, value } = await reader.read()
|
|
390
|
+
if (done) break
|
|
391
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
|
|
392
|
+
if (total + chunk.byteLength > args.maxBytes) {
|
|
393
|
+
try { await reader.cancel() } catch {}
|
|
394
|
+
return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
|
|
395
|
+
}
|
|
396
|
+
chunks.push(chunk)
|
|
397
|
+
total += chunk.byteLength
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
try { await reader.cancel() } catch {}
|
|
401
|
+
throw error
|
|
402
|
+
} finally {
|
|
403
|
+
try { reader.releaseLock?.() } catch {}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (declaredLength && total < declaredLength) {
|
|
407
|
+
return JSON.stringify({ ok: false, message: 'attachment download was truncated' })
|
|
408
|
+
}
|
|
409
|
+
const bytes = new Uint8Array(total)
|
|
410
|
+
let offset = 0
|
|
411
|
+
for (const chunk of chunks) {
|
|
412
|
+
bytes.set(chunk, offset)
|
|
413
|
+
offset += chunk.byteLength
|
|
414
|
+
}
|
|
415
|
+
let binary = ''
|
|
416
|
+
for (let at = 0; at < bytes.length; at += 0x8000) {
|
|
417
|
+
binary += String.fromCharCode(...bytes.subarray(at, Math.min(bytes.length, at + 0x8000)))
|
|
418
|
+
}
|
|
419
|
+
return JSON.stringify({
|
|
420
|
+
ok: true,
|
|
421
|
+
base64: btoa(binary),
|
|
422
|
+
mimeType: response.headers.get('content-type') || 'application/octet-stream',
|
|
423
|
+
name: target.pathname.split('/').pop() || 'attachment',
|
|
424
|
+
})
|
|
425
|
+
} catch (error) {
|
|
426
|
+
return JSON.stringify({ ok: false, message: String(error && error.message || error) })
|
|
427
|
+
}
|
|
428
|
+
}`
|
|
429
|
+
|
|
430
|
+
export const domFallbackScript = String.raw`async function (args) {
|
|
431
|
+
const nodes = Array.from(document.querySelectorAll('[data-message-author-role], user-query, model-response, [class*="message"]'))
|
|
432
|
+
const rows = []
|
|
433
|
+
let ordinal = 0
|
|
434
|
+
for (const node of nodes) {
|
|
435
|
+
if (node.parentElement && node.parentElement.closest('[data-message-author-role], user-query, model-response') !== node) continue
|
|
436
|
+
const attr = (node.getAttribute('data-message-author-role') || '').toLowerCase()
|
|
437
|
+
const tag = node.tagName.toLowerCase()
|
|
438
|
+
const role = attr === 'user' || tag === 'user-query' || /user|human/i.test(node.className || '') ? 'user'
|
|
439
|
+
: attr === 'assistant' || tag === 'model-response' || /assistant|model-response|claude/i.test(node.className || '') ? 'assistant'
|
|
440
|
+
: null
|
|
441
|
+
const text = (node.innerText || node.textContent || '').trim()
|
|
442
|
+
if (!role || !text) continue
|
|
443
|
+
rows.push({
|
|
444
|
+
conversationId: args.id,
|
|
445
|
+
ordinal: ordinal++,
|
|
446
|
+
messageId: node.getAttribute('data-message-id') || '',
|
|
447
|
+
parentId: '', branchId: '', activeBranch: true,
|
|
448
|
+
role, text, createdAt: '', attachmentsJson: '[]', partial: true,
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
return JSON.stringify({ ok: rows.length > 0, rows, message: rows.length ? '' : 'DOM fallback found no messages' })
|
|
452
|
+
}`
|