dsh-reference-anything 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/NOTICE.md +54 -0
- package/README.md +228 -0
- package/cordis.patch.yml +63 -0
- package/images/logo.png +0 -0
- package/lib/client/index.d.ts +3 -0
- package/lib/client.js +18151 -0
- package/lib/contract.d.ts +300 -0
- package/lib/contract.js +102 -0
- package/lib/errors.d.ts +56 -0
- package/lib/errors.js +21 -0
- package/lib/host.d.ts +102 -0
- package/lib/host.js +55 -0
- package/lib/index.d.ts +83 -0
- package/lib/index.js +153 -0
- package/lib/opencli.d.ts +116 -0
- package/lib/opencli.js +356 -0
- package/lib/reference.d.ts +49 -0
- package/lib/reference.js +228 -0
- package/lib/render.d.ts +101 -0
- package/lib/render.js +250 -0
- package/lib/retain.d.ts +35 -0
- package/lib/retain.js +116 -0
- package/lib/search.d.ts +75 -0
- package/lib/search.js +126 -0
- package/lib/serialize.d.ts +22 -0
- package/lib/serialize.js +24 -0
- package/lib/sources/file.d.ts +100 -0
- package/lib/sources/file.js +236 -0
- package/lib/sources/web-chat/index.d.ts +114 -0
- package/lib/sources/web-chat/index.js +359 -0
- package/lib/store/spec.d.ts +312 -0
- package/lib/store/spec.js +94 -0
- package/lib/store/store.d.ts +189 -0
- package/lib/store/store.js +638 -0
- package/lib/sync/index.d.ts +91 -0
- package/lib/sync/index.js +425 -0
- package/lib/tool.d.ts +62 -0
- package/lib/tool.js +364 -0
- package/lib/typert.d.ts +2 -0
- package/lib/typert.js +34 -0
- package/lib/types.d.ts +215 -0
- package/lib/types.js +12 -0
- package/lib/uri-codec.d.ts +5 -0
- package/lib/uri-codec.js +39 -0
- package/lib/uri.d.ts +59 -0
- package/lib/uri.js +86 -0
- package/lib/web.d.ts +4 -0
- package/lib/web.js +11 -0
- package/lib/window.d.ts +23 -0
- package/lib/window.js +35 -0
- package/lib/wire.d.ts +117 -0
- package/lib/wire.js +49 -0
- package/lib/workspace.d.ts +18 -0
- package/lib/workspace.js +81 -0
- package/opencli-plugin/chatgpt.js +125 -0
- package/opencli-plugin/claude.js +134 -0
- package/opencli-plugin/common.js +307 -0
- package/opencli-plugin/deepseek.js +152 -0
- package/opencli-plugin/gemini.js +165 -0
- package/opencli-plugin/grok.js +123 -0
- package/opencli-plugin/kimi.js +123 -0
- package/opencli-plugin/opencli-plugin.json +6 -0
- package/opencli-plugin/package.json +8 -0
- package/opencli-plugin/since-guard.js +45 -0
- package/package.json +180 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { domFallbackScript, registerProvider, sinceGuardSource } from './common.js'
|
|
2
|
+
// OpenCLI discovery marker: registerProvider below performs the cli() calls.
|
|
3
|
+
|
|
4
|
+
const shared = String.raw`
|
|
5
|
+
const organization = async () => {
|
|
6
|
+
const response = await fetch('/api/organizations', { credentials: 'include' })
|
|
7
|
+
if (response.status === 401 || response.status === 403) throw Object.assign(new Error('login required'), { code: 'AUTH' })
|
|
8
|
+
if (!response.ok) throw new Error('organizations HTTP ' + response.status)
|
|
9
|
+
const payload = await response.json()
|
|
10
|
+
const organizations = Array.isArray(payload) ? payload : payload.organizations || []
|
|
11
|
+
const item = organizations.find(value => value && (value.uuid || value.id))
|
|
12
|
+
if (!item) throw Object.assign(new Error('no Claude organization'), { code: 'AUTH' })
|
|
13
|
+
return String(item.uuid || item.id)
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const historyScript = String.raw`async function (args) {
|
|
18
|
+
${shared}
|
|
19
|
+
const stopEarly = (${sinceGuardSource})(args && args.since)
|
|
20
|
+
const rows = []
|
|
21
|
+
try {
|
|
22
|
+
const org = await organization()
|
|
23
|
+
let offset = 0
|
|
24
|
+
for (let page = 0; page < 500; page++) {
|
|
25
|
+
const response = await fetch('/api/organizations/' + encodeURIComponent(org) + '/chat_conversations?limit=100&offset=' + offset, { credentials: 'include' })
|
|
26
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
27
|
+
if (response.status === 429) return JSON.stringify({ ok: false, code: 'RATE_LIMIT' })
|
|
28
|
+
if (!response.ok) throw new Error('history HTTP ' + response.status)
|
|
29
|
+
const payload = await response.json()
|
|
30
|
+
const items = Array.isArray(payload) ? payload : payload.items || payload.chat_conversations || []
|
|
31
|
+
const pageStart = rows.length
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const id = String(item.uuid || item.id || '')
|
|
34
|
+
if (!id) continue
|
|
35
|
+
rows.push({ provider: 'claude', accountScope: '', id,
|
|
36
|
+
title: String(item.name || item.title || 'Untitled Claude chat'),
|
|
37
|
+
url: location.origin + '/chat/' + encodeURIComponent(id),
|
|
38
|
+
createdAt: String(item.created_at || item.createdAt || ''),
|
|
39
|
+
updatedAt: String(item.updated_at || item.updatedAt || item.created_at || ''),
|
|
40
|
+
messageCount: Number(item.message_count || item.messageCount || 0),
|
|
41
|
+
cursor: String(offset + items.length), partial: false })
|
|
42
|
+
}
|
|
43
|
+
if (items.length < 100) break
|
|
44
|
+
if (stopEarly(rows.slice(pageStart))) break
|
|
45
|
+
offset += items.length
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify({ ok: true, rows })
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error && error.code === 'AUTH') return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
50
|
+
const links = Array.from(document.querySelectorAll('a[href*="/chat/"]'))
|
|
51
|
+
const seen = new Set()
|
|
52
|
+
for (const link of links) {
|
|
53
|
+
const match = link.href.match(/\/chat\/([^/?#]+)/)
|
|
54
|
+
if (!match || seen.has(match[1])) continue
|
|
55
|
+
seen.add(match[1])
|
|
56
|
+
rows.push({ provider: 'claude', accountScope: '', id: match[1],
|
|
57
|
+
title: (link.textContent || '').trim() || 'Untitled Claude chat', url: link.href,
|
|
58
|
+
createdAt: '', updatedAt: '', messageCount: 0, cursor: '', partial: true })
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify({ ok: rows.length > 0, rows, message: String(error && error.message || error) })
|
|
61
|
+
}
|
|
62
|
+
}`
|
|
63
|
+
|
|
64
|
+
const detailScript = String.raw`async function (args) {
|
|
65
|
+
${shared}
|
|
66
|
+
const textOf = value => typeof value === 'string' ? value
|
|
67
|
+
: Array.isArray(value) ? value.map(textOf).filter(Boolean).join('\n')
|
|
68
|
+
: value && typeof value === 'object' ? textOf(value.text || value.content || value.value || value.markdown || '') : ''
|
|
69
|
+
try {
|
|
70
|
+
const org = await organization()
|
|
71
|
+
const url = '/api/organizations/' + encodeURIComponent(org) + '/chat_conversations/' + encodeURIComponent(args.id)
|
|
72
|
+
+ '?tree=True&rendering_mode=messages&render_all_tools=true'
|
|
73
|
+
const response = await fetch(url, { credentials: 'include' })
|
|
74
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
75
|
+
if (response.status === 429) return JSON.stringify({ ok: false, code: 'RATE_LIMIT' })
|
|
76
|
+
if (!response.ok) throw new Error('detail HTTP ' + response.status)
|
|
77
|
+
const payload = await response.json()
|
|
78
|
+
const messages = payload.chat_messages || payload.messages || (payload.conversation && payload.conversation.chat_messages) || []
|
|
79
|
+
const byId = new Map(messages.map(message => [String(message.uuid || message.id || ''), message]))
|
|
80
|
+
const childIds = new Set(messages.map(message => String(message.parent_message_uuid || message.parent_uuid || message.parent_id || '')).filter(Boolean))
|
|
81
|
+
const requestedLeaf = String(payload.current_leaf_message_uuid || payload.current_message_uuid || payload.current_node_uuid || '')
|
|
82
|
+
let leaf = requestedLeaf && byId.has(requestedLeaf) ? requestedLeaf : ''
|
|
83
|
+
if (!leaf) {
|
|
84
|
+
const leaves = messages.filter(message => !childIds.has(String(message.uuid || message.id || '')))
|
|
85
|
+
leaf = String((leaves[leaves.length - 1] || {}).uuid || (leaves[leaves.length - 1] || {}).id || '')
|
|
86
|
+
}
|
|
87
|
+
const active = new Set()
|
|
88
|
+
for (let id = leaf, guard = 0; id && guard++ < messages.length + 1;) {
|
|
89
|
+
active.add(id)
|
|
90
|
+
const message = byId.get(id)
|
|
91
|
+
id = message ? String(message.parent_message_uuid || message.parent_uuid || message.parent_id || '') : ''
|
|
92
|
+
}
|
|
93
|
+
const ordered = messages.slice().sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')))
|
|
94
|
+
const rows = ordered.map((message, ordinal) => {
|
|
95
|
+
const id = String(message.uuid || message.id || ordinal)
|
|
96
|
+
const sender = String(message.sender || message.role || '').toLowerCase()
|
|
97
|
+
const content = message.content || message.text || message.content_blocks || ''
|
|
98
|
+
const attachments = (message.attachments || message.files || []).map((file, index) => {
|
|
99
|
+
const candidate = String(file.download_url || file.url || '')
|
|
100
|
+
let locator = ''
|
|
101
|
+
try { if (candidate) { const url = new URL(candidate, location.origin); if (url.origin === location.origin && url.pathname !== '/') locator = url.pathname + url.search } } catch {}
|
|
102
|
+
const name = String(file.name || file.file_name || 'attachment')
|
|
103
|
+
const mimeType = String(file.mime_type || file.mimeType || '')
|
|
104
|
+
return { attachmentId: String(file.id || file.uuid || index), name: String(file.name || file.file_name || 'attachment'),
|
|
105
|
+
kind: mimeType.startsWith('image/') || /\.(?:gif|jpe?g|png|webp)$/i.test(name) ? 'image' : 'file',
|
|
106
|
+
mimeType, size: Number(file.size || 0),
|
|
107
|
+
locator, status: locator ? 'available' : 'unavailable' }
|
|
108
|
+
})
|
|
109
|
+
return { conversationId: args.id, ordinal, messageId: id,
|
|
110
|
+
parentId: String(message.parent_message_uuid || message.parent_uuid || message.parent_id || ''),
|
|
111
|
+
branchId: String(message.branch_uuid || ''), activeBranch: active.size ? active.has(id) : true,
|
|
112
|
+
role: /assistant|claude|model/.test(sender) ? 'assistant' : 'user', text: textOf(content),
|
|
113
|
+
createdAt: String(message.created_at || message.createdAt || ''), attachmentsJson: JSON.stringify(attachments), partial: false }
|
|
114
|
+
}).filter(row => row.text || row.attachmentsJson !== '[]')
|
|
115
|
+
return JSON.stringify({ ok: rows.length > 0, rows })
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error && error.code === 'AUTH') return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
118
|
+
return JSON.stringify({ ok: false, message: String(error && error.message || error) })
|
|
119
|
+
}
|
|
120
|
+
}`
|
|
121
|
+
|
|
122
|
+
registerProvider({
|
|
123
|
+
site: 'dsh-claude', provider: 'Claude', domain: 'claude.ai', home: 'https://claude.ai/',
|
|
124
|
+
conversationUrl: id => `https://claude.ai/chat/${encodeURIComponent(id)}`,
|
|
125
|
+
whoamiScript: String.raw`async function () {
|
|
126
|
+
const response = await fetch('/api/organizations', { credentials: 'include' })
|
|
127
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
128
|
+
if (!response.ok) return JSON.stringify({ ok: false, message: 'organizations HTTP ' + response.status })
|
|
129
|
+
const payload = await response.json(); const values = Array.isArray(payload) ? payload : payload.organizations || []
|
|
130
|
+
const item = values.find(value => value && (value.uuid || value.id)); const identity = String(item && (item.uuid || item.id) || '')
|
|
131
|
+
return JSON.stringify({ ok: Boolean(identity), identity, code: identity ? undefined : 'AUTH' })
|
|
132
|
+
}`,
|
|
133
|
+
historyScript, detailScript, fallbackScript: domFallbackScript,
|
|
134
|
+
})
|
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
function assertResult(result, domain, operation) {
|
|
42
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message || `${operation} requires login`)
|
|
43
|
+
if (result?.code === 'RATE_LIMIT') {
|
|
44
|
+
throw new CommandExecutionError(`${operation}: ${result.message || 'provider rate limit reached'}`)
|
|
45
|
+
}
|
|
46
|
+
if (result?.ok !== true || !Array.isArray(result.rows)) {
|
|
47
|
+
throw new CommandExecutionError(`${operation}: ${result?.message || 'provider adapter did not return rows'}`)
|
|
48
|
+
}
|
|
49
|
+
return result.rows
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Release only the one-shot task tab. Browser Bridge deliberately keeps its
|
|
54
|
+
* empty adapter container alive so the next sync can reuse it; if the user
|
|
55
|
+
* closes that container, Browser Bridge recreates it on demand.
|
|
56
|
+
*/
|
|
57
|
+
async function inTemporaryTab(page, task) {
|
|
58
|
+
try {
|
|
59
|
+
return await task()
|
|
60
|
+
} finally {
|
|
61
|
+
try {
|
|
62
|
+
await page.closeTab?.()
|
|
63
|
+
} catch {
|
|
64
|
+
// Preserve the provider result/error if the browser closed first.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerProvider(config) {
|
|
70
|
+
const browserSession = SYNC_BROWSER_SESSION
|
|
71
|
+
|
|
72
|
+
cli({
|
|
73
|
+
site: config.site,
|
|
74
|
+
name: 'whoami',
|
|
75
|
+
access: 'read',
|
|
76
|
+
description: `Resolve the stable ${config.provider} account identity for local scoping`,
|
|
77
|
+
domain: config.domain,
|
|
78
|
+
strategy: Strategy.COOKIE,
|
|
79
|
+
browser: true,
|
|
80
|
+
...browserSession,
|
|
81
|
+
navigateBefore: false,
|
|
82
|
+
args: [],
|
|
83
|
+
columns: ['identity'],
|
|
84
|
+
func: async (page) => inTemporaryTab(page, async () => {
|
|
85
|
+
await page.goto(config.home, { settleMs: 500 })
|
|
86
|
+
const result = await evaluate(page, config.whoamiScript, {}, `${config.site} whoami`)
|
|
87
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
88
|
+
if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
|
|
89
|
+
throw new CommandExecutionError(`${config.site} whoami: ${result?.message || 'stable account identity unavailable'}`)
|
|
90
|
+
}
|
|
91
|
+
return [{ identity: result.identity }]
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
cli({
|
|
96
|
+
site: config.site,
|
|
97
|
+
name: 'sync-index',
|
|
98
|
+
access: 'read',
|
|
99
|
+
description: `Resolve the ${config.provider} account and list its conversation history in one browser session`,
|
|
100
|
+
domain: config.domain,
|
|
101
|
+
strategy: Strategy.COOKIE,
|
|
102
|
+
browser: true,
|
|
103
|
+
...browserSession,
|
|
104
|
+
navigateBefore: false,
|
|
105
|
+
args: [{
|
|
106
|
+
name: 'since',
|
|
107
|
+
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
108
|
+
}, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
|
|
109
|
+
columns: SYNC_INDEX_COLUMNS,
|
|
110
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
111
|
+
const since = String(kwargs.since || '').trim()
|
|
112
|
+
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
113
|
+
await page.goto(config.home, { settleMs: 1200 })
|
|
114
|
+
const identity = await evaluate(page, config.whoamiScript, {}, `${config.site} sync-index identity`)
|
|
115
|
+
if (identity?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
116
|
+
if (identity?.ok !== true || typeof identity.identity !== 'string' || !identity.identity) {
|
|
117
|
+
throw new CommandExecutionError(`${config.site} sync-index: ${identity?.message || 'stable account identity unavailable'}`)
|
|
118
|
+
}
|
|
119
|
+
const accountScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${identity.identity}`).digest('hex')
|
|
120
|
+
const effectiveSince = String(kwargs.accountScope || '') === accountScope ? since : ''
|
|
121
|
+
const rows = assertResult(
|
|
122
|
+
await evaluate(page, config.historyScript, { since: effectiveSince }, `${config.site} sync-index history`),
|
|
123
|
+
config.domain,
|
|
124
|
+
`${config.site} sync-index`,
|
|
125
|
+
)
|
|
126
|
+
return [
|
|
127
|
+
{ kind: 'identity', identity: identity.identity, sinceApplied: effectiveSince },
|
|
128
|
+
...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
|
|
129
|
+
]
|
|
130
|
+
}),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
cli({
|
|
134
|
+
site: config.site,
|
|
135
|
+
name: 'history-all',
|
|
136
|
+
access: 'read',
|
|
137
|
+
description: `List the ${config.provider} web conversation history, whole or since an instant`,
|
|
138
|
+
domain: config.domain,
|
|
139
|
+
strategy: Strategy.COOKIE,
|
|
140
|
+
browser: true,
|
|
141
|
+
...browserSession,
|
|
142
|
+
navigateBefore: false,
|
|
143
|
+
args: [{
|
|
144
|
+
name: 'since',
|
|
145
|
+
help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
|
|
146
|
+
}],
|
|
147
|
+
columns: HISTORY_COLUMNS,
|
|
148
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
149
|
+
const since = String(kwargs.since || '').trim()
|
|
150
|
+
if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
|
|
151
|
+
await page.goto(config.home, { settleMs: 1200 })
|
|
152
|
+
const rows = assertResult(
|
|
153
|
+
await evaluate(page, config.historyScript, { since }, `${config.site} history-all`),
|
|
154
|
+
config.domain,
|
|
155
|
+
`${config.site} history-all`,
|
|
156
|
+
)
|
|
157
|
+
return rows
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
cli({
|
|
162
|
+
site: config.site,
|
|
163
|
+
name: 'detail',
|
|
164
|
+
access: 'read',
|
|
165
|
+
description: `Read one complete ${config.provider} conversation`,
|
|
166
|
+
domain: config.domain,
|
|
167
|
+
strategy: Strategy.COOKIE,
|
|
168
|
+
browser: true,
|
|
169
|
+
...browserSession,
|
|
170
|
+
navigateBefore: false,
|
|
171
|
+
args: [
|
|
172
|
+
{ name: 'id', positional: true, required: true, help: 'Provider conversation id' },
|
|
173
|
+
{ name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
|
|
174
|
+
],
|
|
175
|
+
columns: DETAIL_COLUMNS,
|
|
176
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
177
|
+
const id = String(kwargs.id || '').trim()
|
|
178
|
+
if (!id) throw new ArgumentError('id must be a non-empty conversation id')
|
|
179
|
+
await page.goto(config.home, { settleMs: 600 })
|
|
180
|
+
const expectedScope = String(kwargs.accountScope || '').trim()
|
|
181
|
+
if (expectedScope) {
|
|
182
|
+
const identity = await evaluate(page, config.whoamiScript, {}, `${config.site} detail identity`)
|
|
183
|
+
if (identity?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
184
|
+
if (identity?.ok !== true || typeof identity.identity !== 'string' || !identity.identity) {
|
|
185
|
+
throw new CommandExecutionError(`${config.site} detail: stable account identity unavailable`)
|
|
186
|
+
}
|
|
187
|
+
const actualScope = createHash('sha256').update(`${config.provider.toLocaleLowerCase()}\0${identity.identity}`).digest('hex')
|
|
188
|
+
if (actualScope !== expectedScope) {
|
|
189
|
+
throw new CommandExecutionError(`DSH_ACCOUNT_SCOPE_MISMATCH: ${config.site} detail: conversation belongs to a different logged-in account`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let result = await evaluate(page, config.detailScript, { id }, `${config.site} detail`)
|
|
193
|
+
if (result?.ok !== true && result?.code !== 'AUTH' && result?.code !== 'RATE_LIMIT' && config.fallbackScript) {
|
|
194
|
+
await page.goto(config.conversationUrl(id), { settleMs: 1800 })
|
|
195
|
+
result = await evaluate(page, config.fallbackScript, { id }, `${config.site} detail fallback`)
|
|
196
|
+
}
|
|
197
|
+
const rows = assertResult(result, config.domain, `${config.site} detail`)
|
|
198
|
+
if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
|
|
199
|
+
return rows
|
|
200
|
+
}),
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
cli({
|
|
204
|
+
site: config.site,
|
|
205
|
+
name: 'attachment',
|
|
206
|
+
access: 'read',
|
|
207
|
+
description: `Materialize one authorized ${config.provider} attachment`,
|
|
208
|
+
domain: config.domain,
|
|
209
|
+
strategy: Strategy.COOKIE,
|
|
210
|
+
browser: true,
|
|
211
|
+
...browserSession,
|
|
212
|
+
navigateBefore: false,
|
|
213
|
+
args: [
|
|
214
|
+
{ name: 'locator', positional: true, required: true, help: 'Stable same-origin attachment locator' },
|
|
215
|
+
{ name: 'output', required: true, help: 'Absolute output file path' },
|
|
216
|
+
{ name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
|
|
217
|
+
],
|
|
218
|
+
columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
|
|
219
|
+
func: async (page, kwargs) => inTemporaryTab(page, async () => {
|
|
220
|
+
const locator = String(kwargs.locator || '').trim()
|
|
221
|
+
const output = resolve(String(kwargs.output || ''))
|
|
222
|
+
const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
|
|
223
|
+
if (!locator) throw new ArgumentError('locator must not be empty')
|
|
224
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 26_214_400) {
|
|
225
|
+
throw new ArgumentError('maxBytes must be between 1 and 26214400')
|
|
226
|
+
}
|
|
227
|
+
await page.goto(config.home, { settleMs: 500 })
|
|
228
|
+
const result = await evaluate(page, ATTACHMENT_SCRIPT, {
|
|
229
|
+
locator,
|
|
230
|
+
origin: new URL(config.home).origin,
|
|
231
|
+
maxBytes,
|
|
232
|
+
}, `${config.site} attachment`)
|
|
233
|
+
if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
|
|
234
|
+
if (result?.ok !== true || typeof result.base64 !== 'string') {
|
|
235
|
+
throw new CommandExecutionError(`${config.site} attachment: ${result?.message || 'attachment unavailable'}`)
|
|
236
|
+
}
|
|
237
|
+
const bytes = Buffer.from(result.base64, 'base64')
|
|
238
|
+
if (bytes.byteLength > maxBytes) throw new CommandExecutionError(`${config.site} attachment exceeds maxBytes`)
|
|
239
|
+
await mkdir(dirname(output), { recursive: true })
|
|
240
|
+
await writeFile(output, bytes)
|
|
241
|
+
return [{
|
|
242
|
+
attachmentId: locator,
|
|
243
|
+
name: result.name || output.split(/[\\/]/).pop() || 'attachment',
|
|
244
|
+
mimeType: result.mimeType || 'application/octet-stream',
|
|
245
|
+
size: bytes.byteLength,
|
|
246
|
+
status: 'available',
|
|
247
|
+
localPath: output,
|
|
248
|
+
}]
|
|
249
|
+
}),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Browser Bridge lifecycle contract shared by every web-chat sync command. */
|
|
254
|
+
export const SYNC_BROWSER_SESSION = Object.freeze({
|
|
255
|
+
siteSession: 'ephemeral',
|
|
256
|
+
defaultWindowMode: 'background',
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const ATTACHMENT_SCRIPT = String.raw`async function (args) {
|
|
260
|
+
try {
|
|
261
|
+
const target = new URL(args.locator, args.origin)
|
|
262
|
+
if (target.origin !== args.origin) return JSON.stringify({ ok: false, message: 'cross-origin locator refused' })
|
|
263
|
+
const response = await fetch(target.href, { credentials: 'include' })
|
|
264
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
265
|
+
if (!response.ok) return JSON.stringify({ ok: false, message: 'attachment HTTP ' + response.status })
|
|
266
|
+
const length = Number(response.headers.get('content-length') || 0)
|
|
267
|
+
if (length > args.maxBytes) return JSON.stringify({ ok: false, message: 'attachment exceeds maxBytes' })
|
|
268
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
269
|
+
if (bytes.byteLength > args.maxBytes) return JSON.stringify({ ok: false, message: 'attachment exceeds maxBytes' })
|
|
270
|
+
let binary = ''
|
|
271
|
+
for (let at = 0; at < bytes.length; at += 0x8000) {
|
|
272
|
+
binary += String.fromCharCode(...bytes.subarray(at, Math.min(bytes.length, at + 0x8000)))
|
|
273
|
+
}
|
|
274
|
+
return JSON.stringify({
|
|
275
|
+
ok: true,
|
|
276
|
+
base64: btoa(binary),
|
|
277
|
+
mimeType: response.headers.get('content-type') || 'application/octet-stream',
|
|
278
|
+
name: target.pathname.split('/').pop() || 'attachment',
|
|
279
|
+
})
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return JSON.stringify({ ok: false, message: String(error && error.message || error) })
|
|
282
|
+
}
|
|
283
|
+
}`
|
|
284
|
+
|
|
285
|
+
export const domFallbackScript = String.raw`async function (args) {
|
|
286
|
+
const nodes = Array.from(document.querySelectorAll('[data-message-author-role], user-query, model-response, [class*="message"]'))
|
|
287
|
+
const rows = []
|
|
288
|
+
let ordinal = 0
|
|
289
|
+
for (const node of nodes) {
|
|
290
|
+
if (node.parentElement && node.parentElement.closest('[data-message-author-role], user-query, model-response') !== node) continue
|
|
291
|
+
const attr = (node.getAttribute('data-message-author-role') || '').toLowerCase()
|
|
292
|
+
const tag = node.tagName.toLowerCase()
|
|
293
|
+
const role = attr === 'user' || tag === 'user-query' || /user|human/i.test(node.className || '') ? 'user'
|
|
294
|
+
: attr === 'assistant' || tag === 'model-response' || /assistant|model-response|claude/i.test(node.className || '') ? 'assistant'
|
|
295
|
+
: null
|
|
296
|
+
const text = (node.innerText || node.textContent || '').trim()
|
|
297
|
+
if (!role || !text) continue
|
|
298
|
+
rows.push({
|
|
299
|
+
conversationId: args.id,
|
|
300
|
+
ordinal: ordinal++,
|
|
301
|
+
messageId: node.getAttribute('data-message-id') || '',
|
|
302
|
+
parentId: '', branchId: '', activeBranch: true,
|
|
303
|
+
role, text, createdAt: '', attachmentsJson: '[]', partial: true,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
return JSON.stringify({ ok: rows.length > 0, rows, message: rows.length ? '' : 'DOM fallback found no messages' })
|
|
307
|
+
}`
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { domFallbackScript, registerProvider, sinceGuardSource } from './common.js'
|
|
2
|
+
// OpenCLI discovery marker: registerProvider below performs the cli() calls.
|
|
3
|
+
|
|
4
|
+
const historyScript = String.raw`async function (args) {
|
|
5
|
+
const stopEarly = (${sinceGuardSource})(args && args.since)
|
|
6
|
+
const normalize = value => Array.isArray(value) ? value : []
|
|
7
|
+
const headers = (() => {
|
|
8
|
+
try {
|
|
9
|
+
const stored = JSON.parse(localStorage.getItem('userToken') || 'null')
|
|
10
|
+
const token = typeof stored === 'string' ? stored : stored && (stored.value || stored.token)
|
|
11
|
+
return token ? { Accept: 'application/json', Authorization: 'Bearer ' + token } : { Accept: 'application/json' }
|
|
12
|
+
} catch { return { Accept: 'application/json' } }
|
|
13
|
+
})()
|
|
14
|
+
const toDate = value => {
|
|
15
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value < 1e12 ? value * 1000 : value).toISOString()
|
|
16
|
+
return String(value || '')
|
|
17
|
+
}
|
|
18
|
+
const rows = []
|
|
19
|
+
let cursorPinned = false
|
|
20
|
+
let cursorUpdatedAt = ''
|
|
21
|
+
let guard = 0
|
|
22
|
+
try {
|
|
23
|
+
do {
|
|
24
|
+
const query = new URLSearchParams({ count: '100', 'lte_cursor.pinned': String(cursorPinned) })
|
|
25
|
+
if (cursorUpdatedAt) query.set('lte_cursor.updated_at', cursorUpdatedAt)
|
|
26
|
+
const response = await fetch('/api/v0/chat_session/fetch_page?' + query, { credentials: 'include', headers })
|
|
27
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
28
|
+
if (response.status === 429) return JSON.stringify({ ok: false, code: 'RATE_LIMIT' })
|
|
29
|
+
if (!response.ok) throw new Error('history HTTP ' + response.status)
|
|
30
|
+
const payload = await response.json()
|
|
31
|
+
if (payload && (payload.code === 40002 || /missing token|unauth/i.test(String(payload.msg || payload.message || '')))) {
|
|
32
|
+
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
33
|
+
}
|
|
34
|
+
const data = payload.data && payload.data.biz_data || payload.biz_data || payload.data || payload
|
|
35
|
+
const items = normalize(data.chat_sessions || data.sessions || data.items || data.conversations)
|
|
36
|
+
const pageStart = rows.length
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const id = String(item.id || item.chat_session_id || item.uuid || '')
|
|
39
|
+
if (!id) continue
|
|
40
|
+
rows.push({
|
|
41
|
+
provider: 'deepseek', accountScope: '', id,
|
|
42
|
+
title: String(item.title || item.name || 'Untitled DeepSeek chat'),
|
|
43
|
+
url: location.origin + '/a/chat/s/' + encodeURIComponent(id),
|
|
44
|
+
createdAt: toDate(item.inserted_at || item.created_at || item.createdAt || ''),
|
|
45
|
+
updatedAt: toDate(item.updated_at || item.updatedAt || item.inserted_at || item.created_at || ''),
|
|
46
|
+
messageCount: Number(item.message_count || item.messageCount || 0),
|
|
47
|
+
cursor: String(item.updated_at || item.updatedAt || ''), partial: false,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
const last = items[items.length - 1]
|
|
51
|
+
const nextUpdatedAt = String(last && (last.updated_at || last.updatedAt) || '')
|
|
52
|
+
const nextPinned = Boolean(last && last.pinned)
|
|
53
|
+
if (!data.has_more || !nextUpdatedAt || (nextUpdatedAt === cursorUpdatedAt && nextPinned === cursorPinned)) break
|
|
54
|
+
if (stopEarly(rows.slice(pageStart))) break
|
|
55
|
+
cursorUpdatedAt = nextUpdatedAt
|
|
56
|
+
cursorPinned = nextPinned
|
|
57
|
+
} while (++guard < 500)
|
|
58
|
+
return JSON.stringify({ ok: true, rows })
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const links = Array.from(document.querySelectorAll('a[href*="/a/chat/s/"]'))
|
|
61
|
+
const seen = new Set()
|
|
62
|
+
for (const link of links) {
|
|
63
|
+
const match = link.href.match(/\/a\/chat\/s\/([^/?#]+)/)
|
|
64
|
+
if (!match || seen.has(match[1])) continue
|
|
65
|
+
seen.add(match[1])
|
|
66
|
+
rows.push({ provider: 'deepseek', accountScope: '', id: match[1],
|
|
67
|
+
title: (link.textContent || '').trim() || 'Untitled DeepSeek chat', url: link.href,
|
|
68
|
+
createdAt: '', updatedAt: '', messageCount: 0, cursor: '', partial: true })
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify({ ok: rows.length > 0, rows, message: String(error && error.message || error) })
|
|
71
|
+
}
|
|
72
|
+
}`
|
|
73
|
+
|
|
74
|
+
const detailScript = String.raw`async function (args) {
|
|
75
|
+
const headers = (() => {
|
|
76
|
+
try {
|
|
77
|
+
const stored = JSON.parse(localStorage.getItem('userToken') || 'null')
|
|
78
|
+
const token = typeof stored === 'string' ? stored : stored && (stored.value || stored.token)
|
|
79
|
+
return token ? { Accept: 'application/json', Authorization: 'Bearer ' + token } : { Accept: 'application/json' }
|
|
80
|
+
} catch { return { Accept: 'application/json' } }
|
|
81
|
+
})()
|
|
82
|
+
const toText = value => typeof value === 'string' ? value
|
|
83
|
+
: Array.isArray(value) ? value.map(toText).filter(Boolean).join('\n')
|
|
84
|
+
: value && typeof value === 'object' ? toText(value.text || value.content || value.value || '') : ''
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch('/api/v0/chat/history_messages?chat_session_id=' + encodeURIComponent(args.id), { credentials: 'include', headers })
|
|
87
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
88
|
+
if (response.status === 429) return JSON.stringify({ ok: false, code: 'RATE_LIMIT' })
|
|
89
|
+
if (!response.ok) throw new Error('detail HTTP ' + response.status)
|
|
90
|
+
const payload = await response.json()
|
|
91
|
+
if (payload && (payload.code === 40002 || /missing token|unauth/i.test(String(payload.msg || payload.message || '')))) {
|
|
92
|
+
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
93
|
+
}
|
|
94
|
+
const data = payload.data && payload.data.biz_data || payload.biz_data || payload.data || payload
|
|
95
|
+
const messages = data.chat_messages || data.messages || data.items || []
|
|
96
|
+
const rows = messages.map((message, ordinal) => {
|
|
97
|
+
const roleRaw = String(message.role || message.message_role || message.sender || '').toLowerCase()
|
|
98
|
+
const role = /assistant|bot|model/.test(roleRaw) ? 'assistant' : 'user'
|
|
99
|
+
const files = message.attachments || message.files || message.file_infos || []
|
|
100
|
+
const attachments = (Array.isArray(files) ? files : []).map((file, index) => {
|
|
101
|
+
const candidate = String(file.download_url || file.url || file.path || '')
|
|
102
|
+
let locator = ''
|
|
103
|
+
try { if (candidate) { const url = new URL(candidate, location.origin); if (url.origin === location.origin && url.pathname !== '/') locator = url.pathname + url.search } } catch {}
|
|
104
|
+
const name = String(file.name || file.file_name || 'attachment')
|
|
105
|
+
const mimeType = String(file.mime_type || file.mimeType || '')
|
|
106
|
+
return { attachmentId: String(file.id || file.file_id || index),
|
|
107
|
+
kind: mimeType.startsWith('image/') || /\.(?:gif|jpe?g|png|webp)$/i.test(name) ? 'image' : 'file',
|
|
108
|
+
name, mimeType,
|
|
109
|
+
size: Number(file.size || file.file_size || 0), locator, status: locator ? 'available' : 'unavailable' }
|
|
110
|
+
})
|
|
111
|
+
return { conversationId: args.id, ordinal,
|
|
112
|
+
messageId: String(message.id || message.message_id || ordinal),
|
|
113
|
+
parentId: String(message.parent_id || message.parentId || ''), branchId: '', activeBranch: true,
|
|
114
|
+
role, text: toText(message.content || message.text || message.fragments || ''),
|
|
115
|
+
createdAt: String(message.created_at || message.createdAt || ''),
|
|
116
|
+
attachmentsJson: JSON.stringify(attachments), partial: false }
|
|
117
|
+
}).filter(row => row.text || row.attachmentsJson !== '[]')
|
|
118
|
+
return JSON.stringify({ ok: rows.length > 0, rows })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return JSON.stringify({ ok: false, message: String(error && error.message || error) })
|
|
121
|
+
}
|
|
122
|
+
}`
|
|
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 () {
|
|
128
|
+
const headers = (() => {
|
|
129
|
+
try {
|
|
130
|
+
const stored = JSON.parse(localStorage.getItem('userToken') || 'null')
|
|
131
|
+
const token = typeof stored === 'string' ? stored : stored && (stored.value || stored.token)
|
|
132
|
+
return token ? { Accept: 'application/json', Authorization: 'Bearer ' + token } : { Accept: 'application/json' }
|
|
133
|
+
} catch { return { Accept: 'application/json' } }
|
|
134
|
+
})()
|
|
135
|
+
for (const url of ['/api/v0/users/current', '/api/v0/user/profile']) {
|
|
136
|
+
const response = await fetch(url, { credentials: 'include', headers })
|
|
137
|
+
if (response.status === 401 || response.status === 403) return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
138
|
+
if (!response.ok) continue
|
|
139
|
+
const contentType = response.headers.get('content-type') || ''
|
|
140
|
+
if (!contentType.includes('application/json')) continue
|
|
141
|
+
const payload = await response.json()
|
|
142
|
+
if (payload && (payload.code === 40002 || /missing token|unauth/i.test(String(payload.msg || payload.message || '')))) {
|
|
143
|
+
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
144
|
+
}
|
|
145
|
+
const user = payload.data && payload.data.biz_data || payload.biz_data || payload.data || payload
|
|
146
|
+
const identity = String(user.id || user.user_id || user.uuid || user.email || '')
|
|
147
|
+
if (identity) return JSON.stringify({ ok: true, identity })
|
|
148
|
+
}
|
|
149
|
+
return JSON.stringify({ ok: false, code: 'AUTH' })
|
|
150
|
+
}`,
|
|
151
|
+
historyScript, detailScript, fallbackScript: domFallbackScript,
|
|
152
|
+
})
|