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.
Files changed (72) hide show
  1. package/LICENSE +22 -22
  2. package/NOTICE.md +110 -110
  3. package/README.md +470 -451
  4. package/cordis.patch.yml +136 -134
  5. package/docs/release/0.4.4/README.md +75 -0
  6. package/{release-notes → docs/release}/0.4.4/features.json +1 -1
  7. package/docs/release/0.5.0/README.md +148 -0
  8. package/docs/release/0.5.1/README.md +33 -0
  9. package/docs/release/0.5.2/README.md +51 -0
  10. package/docs/release/0.5.2/features.json +29 -0
  11. package/{release-notes → docs/release}/README.md +12 -6
  12. package/images/agents/LICENSE +22 -22
  13. package/images/agents/README.md +7 -3
  14. package/images/agents/antigravity.svg +1 -1
  15. package/images/agents/cline.svg +1 -1
  16. package/images/agents/continue.svg +1 -1
  17. package/images/agents/copilot.svg +27 -0
  18. package/images/agents/crush.svg +1 -1
  19. package/images/agents/doubao.svg +1 -0
  20. package/images/agents/goose.svg +1 -1
  21. package/images/agents/omp.svg +1 -0
  22. package/images/agents/reasonix.svg +5 -5
  23. package/images/agents/teleagent.svg +1 -1
  24. package/images/agents/trae.svg +1 -1
  25. package/images/agents/vibe.svg +1 -1
  26. package/images/agents/workbuddy.svg +37 -37
  27. package/images/agents/zed.svg +1 -1
  28. package/lib/chat-import-compatibility.js +8 -5
  29. package/lib/client.js +36526 -4
  30. package/lib/contract.d.ts +4 -0
  31. package/lib/host.d.ts +2 -2
  32. package/lib/host.js +4 -1
  33. package/lib/local-conversation-reader.js +16 -11
  34. package/lib/render.js +21 -21
  35. package/lib/store/spec.d.ts +9 -1
  36. package/lib/tool.js +3 -2
  37. package/lib/wire.d.ts +13 -1
  38. package/lib/wire.js +5 -4
  39. package/locale/en.json +6 -6
  40. package/locale/zh.json +6 -6
  41. package/opencli-plugin/chatgpt.js +128 -128
  42. package/opencli-plugin/claude.js +138 -138
  43. package/opencli-plugin/common.js +452 -452
  44. package/opencli-plugin/deepseek.js +155 -155
  45. package/opencli-plugin/gemini.js +169 -169
  46. package/opencli-plugin/grok.js +124 -124
  47. package/opencli-plugin/kimi.js +126 -126
  48. package/opencli-plugin/opencli-plugin.json +6 -6
  49. package/opencli-plugin/package.json +8 -8
  50. package/opencli-plugin/since-guard.js +45 -45
  51. package/package.json +263 -262
  52. package/vendor/chat-import-reader/LICENSE +22 -22
  53. package/vendor/chat-import-reader/README.md +54 -48
  54. package/vendor/chat-import-reader/availability-worker.mjs +5 -0
  55. package/vendor/chat-import-reader/parsers.d.mts +6 -6
  56. package/vendor/chat-import-reader/parsers.mjs +2248 -347
  57. package/vendor/chat-import-reader/scanners.mjs +1316 -79
  58. package/vendor/chat-import-reader/upstream-sha256.json +35 -22
  59. package/release-notes/0.4.4/commands.png +0 -0
  60. package/release-notes/0.4.4/demos/agent-import.gif +0 -0
  61. package/release-notes/0.4.4/demos/chat-import-upgrade.gif +0 -0
  62. package/release-notes/0.4.4/demos/folders.gif +0 -0
  63. package/release-notes/0.4.4/demos/local-plugins.gif +0 -0
  64. package/release-notes/0.4.4/demos/shortcuts.gif +0 -0
  65. package/release-notes/0.4.4/files.png +0 -0
  66. package/release-notes/0.5.0/demos/agent-import.gif +0 -0
  67. package/release-notes/0.5.0/demos/chat-import-upgrade.gif +0 -0
  68. package/release-notes/0.5.0/demos/folders.gif +0 -0
  69. package/release-notes/0.5.0/demos/local-plugins.gif +0 -0
  70. package/release-notes/0.5.0/demos/local-references.gif +0 -0
  71. package/release-notes/0.5.0/demos/shortcuts.gif +0 -0
  72. /package/{release-notes → docs/release}/0.5.0/features.json +0 -0
@@ -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
+ }`