dsh-reference-anything 0.3.0 → 0.3.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 (109) hide show
  1. package/LICENSE +22 -22
  2. package/NOTICE.md +52 -25
  3. package/README.md +339 -286
  4. package/cordis.patch.yml +89 -18
  5. package/lib/client.js +13538 -591
  6. package/lib/contract.d.ts +155 -16
  7. package/lib/contract.js +68 -7
  8. package/lib/download-directory.d.ts +18 -0
  9. package/lib/download-directory.js +55 -0
  10. package/lib/errors.d.ts +4 -0
  11. package/lib/host.d.ts +96 -8
  12. package/lib/host.js +88 -19
  13. package/lib/index.d.ts +22 -2
  14. package/lib/index.js +57 -7
  15. package/lib/opencli.d.ts +5 -3
  16. package/lib/opencli.js +13 -6
  17. package/lib/openlist/host.d.ts +157 -0
  18. package/lib/openlist/host.js +873 -0
  19. package/lib/openlist/index.d.ts +84 -0
  20. package/lib/openlist/index.js +507 -0
  21. package/lib/render.d.ts +12 -7
  22. package/lib/render.js +27 -21
  23. package/lib/sources/cloud-drive/cache.d.ts +60 -0
  24. package/lib/sources/cloud-drive/cache.js +107 -0
  25. package/lib/sources/cloud-drive/index.d.ts +190 -0
  26. package/lib/sources/cloud-drive/index.js +526 -0
  27. package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
  28. package/lib/sources/cloud-drive/providers/http.js +73 -0
  29. package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
  30. package/lib/sources/cloud-drive/providers/openlist.js +535 -0
  31. package/lib/sources/cloud-drive/registry.d.ts +53 -0
  32. package/lib/sources/cloud-drive/registry.js +101 -0
  33. package/lib/sources/cloud-drive/types.d.ts +169 -0
  34. package/lib/sources/cloud-drive/types.js +17 -0
  35. package/lib/sources/file.d.ts +2 -0
  36. package/lib/sources/file.js +2 -0
  37. package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
  38. package/lib/sources/local-agent/adapters/claude-code.js +171 -0
  39. package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
  40. package/lib/sources/local-agent/adapters/codex.js +204 -0
  41. package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
  42. package/lib/sources/local-agent/adapters/cursor.js +121 -0
  43. package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
  44. package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
  45. package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
  46. package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
  47. package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
  48. package/lib/sources/local-agent/adapters/hermes.js +85 -0
  49. package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
  50. package/lib/sources/local-agent/adapters/kimi.js +261 -0
  51. package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
  52. package/lib/sources/local-agent/adapters/openclaw.js +120 -0
  53. package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
  54. package/lib/sources/local-agent/adapters/opencode.js +236 -0
  55. package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
  56. package/lib/sources/local-agent/adapters/pi.js +242 -0
  57. package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
  58. package/lib/sources/local-agent/adapters/qoder.js +93 -0
  59. package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
  60. package/lib/sources/local-agent/adapters/reasonix.js +90 -0
  61. package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
  62. package/lib/sources/local-agent/adapters/shared.js +415 -0
  63. package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
  64. package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
  65. package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
  66. package/lib/sources/local-agent/adapters/zcode.js +159 -0
  67. package/lib/sources/local-agent/index.d.ts +222 -0
  68. package/lib/sources/local-agent/index.js +656 -0
  69. package/lib/sources/local-agent/page.d.ts +55 -0
  70. package/lib/sources/local-agent/page.js +83 -0
  71. package/lib/sources/local-agent/path.d.ts +8 -0
  72. package/lib/sources/local-agent/path.js +43 -0
  73. package/lib/sources/local-agent/registry.d.ts +68 -0
  74. package/lib/sources/local-agent/registry.js +116 -0
  75. package/lib/sources/local-agent/scan.d.ts +175 -0
  76. package/lib/sources/local-agent/scan.js +472 -0
  77. package/lib/sources/local-agent/sqlite.d.ts +45 -0
  78. package/lib/sources/local-agent/sqlite.js +113 -0
  79. package/lib/sources/local-agent/store.d.ts +93 -0
  80. package/lib/sources/local-agent/store.js +113 -0
  81. package/lib/sources/local-agent/types.d.ts +338 -0
  82. package/lib/sources/local-agent/types.js +20 -0
  83. package/lib/sources/web-chat/index.d.ts +5 -1
  84. package/lib/sources/web-chat/index.js +47 -8
  85. package/lib/store/spec.d.ts +50 -4
  86. package/lib/store/spec.js +2 -2
  87. package/lib/sync/index.d.ts +1 -0
  88. package/lib/sync/index.js +28 -8
  89. package/lib/tool.d.ts +5 -0
  90. package/lib/tool.js +418 -45
  91. package/lib/typert.js +13 -2
  92. package/lib/types.d.ts +28 -2
  93. package/lib/update.d.ts +6 -0
  94. package/lib/update.js +29 -1
  95. package/lib/web.js +5 -1
  96. package/lib/wire.d.ts +80 -9
  97. package/lib/wire.js +30 -4
  98. package/opencli-plugin/chatgpt.js +30 -27
  99. package/opencli-plugin/claude.js +12 -8
  100. package/opencli-plugin/common.js +266 -187
  101. package/opencli-plugin/deepseek.js +8 -5
  102. package/opencli-plugin/gemini.js +42 -38
  103. package/opencli-plugin/grok.js +22 -21
  104. package/opencli-plugin/kimi.js +34 -31
  105. package/opencli-plugin/opencli-plugin.json +6 -6
  106. package/opencli-plugin/package.json +8 -8
  107. package/package.json +182 -167
  108. package/lib/profile-mode.d.ts +0 -30
  109. package/lib/profile-mode.js +0 -90
@@ -1,5 +1,5 @@
1
- import { mkdir, writeFile } from 'node:fs/promises'
2
- import { createHash } from 'node:crypto'
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry'
5
5
  import { sinceGuardSource } from './since-guard.js'
@@ -12,12 +12,12 @@ import {
12
12
 
13
13
  export { sinceGuardSource }
14
14
 
15
- const HISTORY_COLUMNS = [
15
+ const HISTORY_COLUMNS = [
16
16
  'provider', 'accountScope', 'id', 'title', 'url', 'createdAt', 'updatedAt',
17
17
  'messageCount', 'cursor', 'partial',
18
- ]
19
-
20
- const SYNC_INDEX_COLUMNS = ['kind', 'identity', 'sinceApplied', ...HISTORY_COLUMNS]
18
+ ]
19
+
20
+ const SYNC_INDEX_COLUMNS = ['kind', 'identity', 'sinceApplied', ...HISTORY_COLUMNS]
21
21
 
22
22
  const DETAIL_COLUMNS = [
23
23
  'conversationId', 'ordinal', 'messageId', 'parentId', 'branchId',
@@ -38,7 +38,24 @@ async function evaluate(page, script, args, operation) {
38
38
  return parseEvaluate(await page.evaluate(source), operation)
39
39
  }
40
40
 
41
- function assertResult(result, domain, operation) {
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) {
42
59
  if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message || `${operation} requires login`)
43
60
  if (result?.code === 'RATE_LIMIT') {
44
61
  throw new CommandExecutionError(`${operation}: ${result.message || 'provider rate limit reached'}`)
@@ -46,74 +63,85 @@ function assertResult(result, domain, operation) {
46
63
  if (result?.ok !== true || !Array.isArray(result.rows)) {
47
64
  throw new CommandExecutionError(`${operation}: ${result?.message || 'provider adapter did not return rows'}`)
48
65
  }
49
- return result.rows
50
- }
51
-
52
- const PROVIDER_READY_TIMEOUT_MS = 1200
53
- const PROVIDER_READY_INITIAL_DELAY_MS = 50
54
- const PROVIDER_READY_MAX_DELAY_MS = 400
55
-
56
- function terminalProviderResult(result) {
57
- return result?.ok === true || result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
58
- }
59
-
60
- function terminalIdentityResult(result) {
61
- return result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
62
- || (result?.ok === true && typeof result.identity === 'string' && result.identity.length > 0)
63
- }
64
-
65
- /**
66
- * Run the provider API as the readiness probe. The happy path performs one
67
- * evaluate immediately after navigation; transient bootstrap failures retry
68
- * with bounded exponential backoff instead of waiting for the whole SPA DOM
69
- * to become quiet first.
70
- */
71
- export async function evaluateWhenProviderReady(page, script, args, operation, options = {}) {
72
- const timeoutMs = options.timeoutMs ?? PROVIDER_READY_TIMEOUT_MS
73
- const maxDelayMs = options.maxDelayMs ?? PROVIDER_READY_MAX_DELAY_MS
74
- const accepts = options.accepts ?? terminalProviderResult
75
- const deadline = Date.now() + timeoutMs
76
- let delayMs = options.initialDelayMs ?? PROVIDER_READY_INITIAL_DELAY_MS
77
- let lastResult
78
- let lastError
79
- for (;;) {
80
- try {
81
- lastResult = await evaluate(page, script, args, operation)
82
- lastError = undefined
83
- if (accepts(lastResult)) return lastResult
84
- } catch (error) {
85
- lastError = error
86
- }
87
- const remaining = deadline - Date.now()
88
- if (remaining <= 0) {
89
- if (lastError) throw lastError
90
- return lastResult
91
- }
92
- await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining)))
93
- delayMs = Math.min(Math.max(delayMs * 2, 1), maxDelayMs)
94
- }
95
- }
96
-
97
- /**
98
- * Each command owns an ephemeral Browser Bridge tab. Close that tab after the
99
- * command finishes while preserving the provider result or failure.
100
- */
101
- async function inTemporaryTab(page, task) {
102
- try {
103
- return await task()
104
- } finally {
105
- try {
106
- await page.closeTab?.()
107
- } catch {
108
- // Preserve the provider result/error if the tab or browser closed first.
109
- }
110
- }
111
- }
112
-
113
- export function registerProvider(config) {
114
- const browserSession = SYNC_BROWSER_SESSION
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 = 1200
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
+ }
115
140
 
116
- cli({
141
+ export function registerProvider(config) {
142
+ const browserSession = SYNC_BROWSER_SESSION
143
+
144
+ cli({
117
145
  site: config.site,
118
146
  name: 'whoami',
119
147
  access: 'read',
@@ -124,61 +152,54 @@ export function registerProvider(config) {
124
152
  ...browserSession,
125
153
  navigateBefore: false,
126
154
  args: [],
127
- columns: ['identity'],
128
- func: async (page) => inTemporaryTab(page, async () => {
129
- await page.goto(config.home, { waitUntil: 'none' })
130
- const result = await evaluateWhenProviderReady(
131
- page, config.whoamiScript, {}, `${config.site} whoami`, { accepts: terminalIdentityResult },
132
- )
133
- if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
134
- if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
135
- throw new CommandExecutionError(`${config.site} whoami: ${result?.message || 'stable account identity unavailable'}`)
136
- }
137
- return [{ identity: result.identity }]
138
- }),
139
- })
140
-
141
- cli({
142
- site: config.site,
143
- name: 'sync-index',
144
- access: 'read',
145
- description: `Resolve the ${config.provider} account and list its conversation history in one browser session`,
146
- domain: config.domain,
147
- strategy: Strategy.COOKIE,
148
- browser: true,
149
- ...browserSession,
150
- navigateBefore: false,
151
- args: [{
152
- name: 'since',
153
- help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
154
- }, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
155
- columns: SYNC_INDEX_COLUMNS,
156
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
157
- const since = String(kwargs.since || '').trim()
158
- if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
159
- await page.goto(config.home, { waitUntil: 'none' })
160
- const identity = await evaluateWhenProviderReady(
161
- page, config.whoamiScript, {}, `${config.site} sync-index identity`, { accepts: terminalIdentityResult },
162
- )
163
- if (identity?.code === 'AUTH') throw new AuthRequiredError(config.domain)
164
- if (identity?.ok !== true || typeof identity.identity !== 'string' || !identity.identity) {
165
- throw new CommandExecutionError(`${config.site} sync-index: ${identity?.message || 'stable account identity unavailable'}`)
166
- }
167
- const accountScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${identity.identity}`).digest('hex')
168
- const effectiveSince = String(kwargs.accountScope || '') === accountScope ? since : ''
169
- const rows = assertResult(
170
- await evaluate(page, config.historyScript, { since: effectiveSince }, `${config.site} sync-index history`),
171
- config.domain,
172
- `${config.site} sync-index`,
173
- )
174
- return [
175
- { kind: 'identity', identity: identity.identity, sinceApplied: effectiveSince },
176
- ...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
177
- ]
178
- }),
179
- })
180
-
181
- cli({
155
+ columns: ['identity'],
156
+ func: async (page) => inTemporaryTab(page, async () => {
157
+ await page.goto(config.home, { waitUntil: 'none' })
158
+ const result = await evaluateWhenProviderReady(
159
+ page, config.whoamiScript, {}, `${config.site} whoami`, { accepts: terminalIdentityResult },
160
+ )
161
+ return [{ identity: assertIdentityResult(result, config.domain, `${config.site} whoami`) }]
162
+ }),
163
+ })
164
+
165
+ cli({
166
+ site: config.site,
167
+ name: 'sync-index',
168
+ access: 'read',
169
+ description: `Resolve the ${config.provider} account and list its conversation history in one browser session`,
170
+ domain: config.domain,
171
+ strategy: Strategy.COOKIE,
172
+ browser: true,
173
+ ...browserSession,
174
+ navigateBefore: false,
175
+ args: [{
176
+ name: 'since',
177
+ help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
178
+ }, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
179
+ columns: SYNC_INDEX_COLUMNS,
180
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
181
+ const since = String(kwargs.since || '').trim()
182
+ if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
183
+ await page.goto(config.home, { waitUntil: 'none' })
184
+ const identity = await evaluateWhenProviderReady(
185
+ page, config.whoamiScript, {}, `${config.site} sync-index identity`, { accepts: terminalIdentityResult },
186
+ )
187
+ const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} sync-index`)
188
+ const accountScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
189
+ const effectiveSince = String(kwargs.accountScope || '') === accountScope ? since : ''
190
+ const rows = assertResult(
191
+ await evaluate(page, config.historyScript, { since: effectiveSince }, `${config.site} sync-index history`),
192
+ config.domain,
193
+ `${config.site} sync-index`,
194
+ )
195
+ return [
196
+ { kind: 'identity', identity: stableIdentity, sinceApplied: effectiveSince },
197
+ ...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
198
+ ]
199
+ }),
200
+ })
201
+
202
+ cli({
182
203
  site: config.site,
183
204
  name: 'history-all',
184
205
  access: 'read',
@@ -193,17 +214,17 @@ export function registerProvider(config) {
193
214
  help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
194
215
  }],
195
216
  columns: HISTORY_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 page.goto(config.home, { waitUntil: 'none' })
200
- const rows = assertResult(
201
- await evaluateWhenProviderReady(page, config.historyScript, { since }, `${config.site} history-all`),
202
- config.domain,
203
- `${config.site} history-all`,
204
- )
205
- return rows
206
- }),
217
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
218
+ const since = String(kwargs.since || '').trim()
219
+ if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
220
+ await page.goto(config.home, { waitUntil: 'none' })
221
+ const rows = assertResult(
222
+ await evaluateWhenProviderReady(page, config.historyScript, { since }, `${config.site} history-all`),
223
+ config.domain,
224
+ `${config.site} history-all`,
225
+ )
226
+ return rows
227
+ }),
207
228
  })
208
229
 
209
230
  cli({
@@ -216,36 +237,33 @@ export function registerProvider(config) {
216
237
  browser: true,
217
238
  ...browserSession,
218
239
  navigateBefore: false,
219
- args: [
220
- { name: 'id', positional: true, required: true, help: 'Provider conversation id' },
221
- { name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
222
- ],
240
+ args: [
241
+ { name: 'id', positional: true, required: true, help: 'Provider conversation id' },
242
+ { name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
243
+ ],
223
244
  columns: DETAIL_COLUMNS,
224
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
225
- const id = String(kwargs.id || '').trim()
226
- if (!id) throw new ArgumentError('id must be a non-empty conversation id')
227
- await page.goto(config.home, { settleMs: 600 })
228
- const expectedScope = String(kwargs.accountScope || '').trim()
229
- if (expectedScope) {
230
- const identity = await evaluate(page, config.whoamiScript, {}, `${config.site} detail identity`)
231
- if (identity?.code === 'AUTH') throw new AuthRequiredError(config.domain)
232
- if (identity?.ok !== true || typeof identity.identity !== 'string' || !identity.identity) {
233
- throw new CommandExecutionError(`${config.site} detail: stable account identity unavailable`)
234
- }
235
- const actualScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${identity.identity}`).digest('hex')
236
- if (actualScope !== expectedScope) {
237
- throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${config.site} detail: conversation belongs to a different logged-in account`)
238
- }
239
- }
240
- let result = await evaluate(page, config.detailScript, { id }, `${config.site} detail`)
245
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
246
+ const id = String(kwargs.id || '').trim()
247
+ if (!id) throw new ArgumentError('id must be a non-empty conversation id')
248
+ await page.goto(config.home, { settleMs: 600 })
249
+ const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
250
+ if (expectedScope) {
251
+ const identity = await evaluate(page, config.whoamiScript, {}, `${config.site} detail identity`)
252
+ const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} detail`)
253
+ const actualScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${stableIdentity}`).digest('hex')
254
+ if (actualScope !== expectedScope) {
255
+ throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${config.site} detail: conversation belongs to a different logged-in account`)
256
+ }
257
+ }
258
+ let result = await evaluate(page, config.detailScript, { id }, `${config.site} detail`)
241
259
  if (result?.ok !== true && result?.code !== 'AUTH' && result?.code !== 'RATE_LIMIT' && config.fallbackScript) {
242
260
  await page.goto(config.conversationUrl(id), { settleMs: 1800 })
243
261
  result = await evaluate(page, config.fallbackScript, { id }, `${config.site} detail fallback`)
244
262
  }
245
- const rows = assertResult(result, config.domain, `${config.site} detail`)
246
- if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
247
- return rows
248
- }),
263
+ const rows = assertResult(result, config.domain, `${config.site} detail`)
264
+ if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
265
+ return rows
266
+ }),
249
267
  })
250
268
 
251
269
  cli({
@@ -262,10 +280,11 @@ export function registerProvider(config) {
262
280
  { name: 'locator', positional: true, required: true, help: 'Stable same-origin attachment locator' },
263
281
  { name: 'output', required: true, help: 'Absolute output file path' },
264
282
  { name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
283
+ { name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the download' },
265
284
  ],
266
285
  columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
267
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
268
- const locator = String(kwargs.locator || '').trim()
286
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
287
+ const locator = String(kwargs.locator || '').trim()
269
288
  const output = resolve(String(kwargs.output || ''))
270
289
  const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
271
290
  if (!locator) throw new ArgumentError('locator must not be empty')
@@ -273,17 +292,30 @@ export function registerProvider(config) {
273
292
  throw new ArgumentError('maxBytes must be between 1 and 26214400')
274
293
  }
275
294
  await page.goto(config.home, { settleMs: 500 })
295
+ const expectedScope = parseExpectedAccountScope(kwargs.accountScope)
296
+ if (expectedScope) {
297
+ const identity = await evaluateWhenProviderReady(
298
+ page, config.whoamiScript, {}, `${config.site} attachment identity`, { accepts: terminalIdentityResult },
299
+ )
300
+ const stableIdentity = assertIdentityResult(identity, config.domain, `${config.site} attachment`)
301
+ verifyAccountScope(config.provider, config.site, expectedScope, stableIdentity)
302
+ }
276
303
  const result = await evaluate(page, ATTACHMENT_SCRIPT, {
277
304
  locator,
278
305
  origin: new URL(config.home).origin,
279
306
  maxBytes,
280
307
  }, `${config.site} attachment`)
281
308
  if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
309
+ if (result?.code === 'TOO_LARGE') {
310
+ throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
311
+ }
282
312
  if (result?.ok !== true || typeof result.base64 !== 'string') {
283
313
  throw new CommandExecutionError(`${config.site} attachment: ${result?.message || 'attachment unavailable'}`)
284
314
  }
285
315
  const bytes = Buffer.from(result.base64, 'base64')
286
- if (bytes.byteLength > maxBytes) throw new CommandExecutionError(`${config.site} attachment exceeds maxBytes`)
316
+ if (bytes.byteLength > maxBytes) {
317
+ throw new CommandExecutionError(`DSH_ATTACHMENT_TOO_LARGE: ${config.site} attachment exceeds maxBytes`)
318
+ }
287
319
  await mkdir(dirname(output), { recursive: true })
288
320
  await writeFile(output, bytes)
289
321
  return [{
@@ -291,30 +323,77 @@ export function registerProvider(config) {
291
323
  name: result.name || output.split(/[\\/]/).pop() || 'attachment',
292
324
  mimeType: result.mimeType || 'application/octet-stream',
293
325
  size: bytes.byteLength,
294
- status: 'available',
295
- localPath: output,
296
- }]
297
- }),
298
- })
299
- }
300
-
301
- /** Browser Bridge lifecycle shared by every web-chat command. */
302
- export const SYNC_BROWSER_SESSION = Object.freeze({
303
- siteSession: 'ephemeral',
304
- defaultWindowMode: 'background',
305
- })
326
+ status: 'available',
327
+ localPath: output,
328
+ }]
329
+ }),
330
+ })
331
+ }
306
332
 
307
- const ATTACHMENT_SCRIPT = String.raw`async function (args) {
333
+ /** Browser Bridge lifecycle shared by every web-chat command. */
334
+ export const SYNC_BROWSER_SESSION = Object.freeze({
335
+ siteSession: 'ephemeral',
336
+ defaultWindowMode: 'background',
337
+ })
338
+
339
+ export const ATTACHMENT_SCRIPT = String.raw`async function (args) {
308
340
  try {
341
+ const cancelBody = async body => {
342
+ try { await body?.cancel?.() } catch {}
343
+ }
309
344
  const target = new URL(args.locator, args.origin)
310
345
  if (target.origin !== args.origin) return JSON.stringify({ ok: false, message: 'cross-origin locator refused' })
311
- const response = await fetch(target.href, { credentials: 'include' })
312
- if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
313
- if (!response.ok) return JSON.stringify({ ok: false, message: 'attachment HTTP ' + response.status })
346
+ const response = await fetch(target.href, { credentials: 'include', redirect: 'error' })
347
+ if (response.url && new URL(response.url).origin !== args.origin) {
348
+ await cancelBody(response.body)
349
+ return JSON.stringify({ ok: false, message: 'cross-origin redirect refused' })
350
+ }
351
+ if (response.status === 401 || response.status === 403) {
352
+ await cancelBody(response.body)
353
+ return JSON.stringify({ ok: false, code: 'AUTH' })
354
+ }
355
+ if (!response.ok) {
356
+ await cancelBody(response.body)
357
+ return JSON.stringify({ ok: false, message: 'attachment HTTP ' + response.status })
358
+ }
314
359
  const length = Number(response.headers.get('content-length') || 0)
315
- if (length > args.maxBytes) return JSON.stringify({ ok: false, message: 'attachment exceeds maxBytes' })
316
- const bytes = new Uint8Array(await response.arrayBuffer())
317
- if (bytes.byteLength > args.maxBytes) return JSON.stringify({ ok: false, message: 'attachment exceeds maxBytes' })
360
+ const declaredLength = Number.isFinite(length) && length > 0 ? length : 0
361
+ if (declaredLength > args.maxBytes) {
362
+ await cancelBody(response.body)
363
+ return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
364
+ }
365
+ const chunks = []
366
+ let total = 0
367
+ if (response.body) {
368
+ const reader = response.body.getReader()
369
+ try {
370
+ for (;;) {
371
+ const { done, value } = await reader.read()
372
+ if (done) break
373
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
374
+ if (total + chunk.byteLength > args.maxBytes) {
375
+ try { await reader.cancel() } catch {}
376
+ return JSON.stringify({ ok: false, code: 'TOO_LARGE', message: 'attachment exceeds maxBytes' })
377
+ }
378
+ chunks.push(chunk)
379
+ total += chunk.byteLength
380
+ }
381
+ } catch (error) {
382
+ try { await reader.cancel() } catch {}
383
+ throw error
384
+ } finally {
385
+ try { reader.releaseLock?.() } catch {}
386
+ }
387
+ }
388
+ if (declaredLength && total < declaredLength) {
389
+ return JSON.stringify({ ok: false, message: 'attachment download was truncated' })
390
+ }
391
+ const bytes = new Uint8Array(total)
392
+ let offset = 0
393
+ for (const chunk of chunks) {
394
+ bytes.set(chunk, offset)
395
+ offset += chunk.byteLength
396
+ }
318
397
  let binary = ''
319
398
  for (let at = 0; at < bytes.length; at += 0x8000) {
320
399
  binary += String.fromCharCode(...bytes.subarray(at, Math.min(bytes.length, at + 0x8000)))
@@ -121,10 +121,7 @@ const detailScript = String.raw`async function (args) {
121
121
  }
122
122
  }`
123
123
 
124
- registerProvider({
125
- site: 'dsh-deepseek', provider: 'DeepSeek', domain: 'chat.deepseek.com',
126
- home: 'https://chat.deepseek.com/', conversationUrl: id => `https://chat.deepseek.com/a/chat/s/${encodeURIComponent(id)}`,
127
- whoamiScript: String.raw`async function () {
124
+ export const whoamiScript = String.raw`async function () {
128
125
  const headers = (() => {
129
126
  try {
130
127
  const stored = JSON.parse(localStorage.getItem('userToken') || 'null')
@@ -135,6 +132,7 @@ registerProvider({
135
132
  for (const url of ['/api/v0/users/current', '/api/v0/user/profile']) {
136
133
  const response = await fetch(url, { credentials: 'include', headers })
137
134
  if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
135
+ if (response.status === 429) return JSON.stringify({ ok: false, code: 'RATE_LIMIT' })
138
136
  if (!response.ok) continue
139
137
  const contentType = response.headers.get('content-type') || ''
140
138
  if (!contentType.includes('application/json')) continue
@@ -147,6 +145,11 @@ registerProvider({
147
145
  if (identity) return JSON.stringify({ ok: true, identity })
148
146
  }
149
147
  return JSON.stringify({ ok: false, code: 'AUTH' })
150
- }`,
148
+ }`
149
+
150
+ registerProvider({
151
+ site: 'dsh-deepseek', provider: 'DeepSeek', domain: 'chat.deepseek.com',
152
+ home: 'https://chat.deepseek.com/', conversationUrl: id => `https://chat.deepseek.com/a/chat/s/${encodeURIComponent(id)}`,
153
+ whoamiScript,
151
154
  historyScript, detailScript, fallbackScript: domFallbackScript,
152
155
  })