dsh-webseek 0.0.0-stage → 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 +21 -0
- package/README.md +106 -2
- package/cordis.patch.yml +14 -0
- package/docs/images/import-dialog.png +0 -0
- package/docs/images/sidebar-entry.png +0 -0
- package/lib/client.js +924 -0
- package/lib/index.js +688 -0
- package/lib/share.js +161 -0
- package/package.json +67 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-webseek — host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves the plugin's /api/dsh-webseek routes and owns everything durable:
|
|
5
|
+
* the import ledger (idempotency) and the writing of an imported
|
|
6
|
+
* conversation as a real DSH session.
|
|
7
|
+
*
|
|
8
|
+
* The plugin's whole job now is one thing: a *public* DeepSeek share link
|
|
9
|
+
* (`https://chat.deepseek.com/share/<id>`) becomes a DSH session in a chosen
|
|
10
|
+
* workspace, with the same title. The content comes from the same endpoint
|
|
11
|
+
* the web app uses, so no login and no embedded browser are involved:
|
|
12
|
+
*
|
|
13
|
+
* GET https://chat.deepseek.com/api/v0/share/content?share_id=<id>
|
|
14
|
+
*
|
|
15
|
+
* The pure half (id check, main-line walk, message mapping) lives in
|
|
16
|
+
* ./share.js so it can be unit-tested offline.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { request as httpsRequest } from 'node:https'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import { mapShareMessages, parseShareId, shareTitle, SHARE_BODY_LIMIT, SHARE_CONTENT_URL } from './share.js'
|
|
25
|
+
|
|
26
|
+
/** Stable cordis plugin name — must equal the `id` in cordis.patch.yml. */
|
|
27
|
+
export const name = 'webseek'
|
|
28
|
+
|
|
29
|
+
/** The only host service this plugin needs. */
|
|
30
|
+
export const inject = ['webServer']
|
|
31
|
+
|
|
32
|
+
/** Test hook: lets the test suite redirect the state directory. */
|
|
33
|
+
let stateDirOverride = null
|
|
34
|
+
|
|
35
|
+
/** @returns the plugin's durable state directory (~/.dsh/dsh-webseek). */
|
|
36
|
+
export function stateDir() {
|
|
37
|
+
return stateDirOverride ?? join(homedir(), '.dsh', 'dsh-webseek')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param dir absolute directory to use instead of the default. */
|
|
41
|
+
export function setStateDir(dir) {
|
|
42
|
+
stateDirOverride = dir
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns path of the import ledger (ledger key → DSH session id). */
|
|
46
|
+
export function ledgerFile() {
|
|
47
|
+
return join(stateDir(), 'ledger.json')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Session log format version stamped by DSH 0.2.x (engines.dsh pins <0.3.0). */
|
|
51
|
+
export const SESSION_FORMAT_VERSION = 4
|
|
52
|
+
|
|
53
|
+
/** The version this host is actually running, read from its own package.json. */
|
|
54
|
+
export const PLUGIN_VERSION = (() => {
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
57
|
+
return typeof pkg?.version === 'string' && pkg.version !== '' ? pkg.version : '0.0.0'
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return '0.0.0'
|
|
60
|
+
}
|
|
61
|
+
})()
|
|
62
|
+
|
|
63
|
+
/** Longest imported title before truncation. */
|
|
64
|
+
const TITLE_MAX = 80
|
|
65
|
+
|
|
66
|
+
/** How long the share request may take before it is given up on. */
|
|
67
|
+
const SHARE_TIMEOUT_MS = 10000
|
|
68
|
+
|
|
69
|
+
/** How long the npm registry may take to answer the version check. */
|
|
70
|
+
const UPDATE_TIMEOUT_MS = 8000
|
|
71
|
+
|
|
72
|
+
/** Upper bound for a request body; an import request is two short strings. */
|
|
73
|
+
const BODY_LIMIT = 64 * 1024
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A browser-shaped User-Agent. The edge answers a bare client with its own
|
|
77
|
+
* HTML error page instead of JSON, and the share endpoint is a browser API.
|
|
78
|
+
*/
|
|
79
|
+
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Translate a fetched DeepSeek conversation into a DSH session event batch.
|
|
83
|
+
*
|
|
84
|
+
* Shape contract (verified against a live session log, DSH 0.2.1-rc.2):
|
|
85
|
+
* - events are `{type, seq, time, data}`; surface messages (user/message,
|
|
86
|
+
* assistant/message) additionally carry the outer `surfaceOp:'append'`; `seq` must start at 0 and be
|
|
87
|
+
* contiguous (the backend asserts `seq === cursor + index`);
|
|
88
|
+
* - `user/message` data is the message itself (`{content, source, role, id}`);
|
|
89
|
+
* - `assistant/message` wraps it (`{turn, step, message, stream}`);
|
|
90
|
+
* - `assistant.message.source` is `{kind:'model', provider, model}` —
|
|
91
|
+
* `replayState` is optional and omitted for replayed history;
|
|
92
|
+
* - `session/end-seed` closes the seed prefix; `session/title` names the
|
|
93
|
+
* session with `source:{kind:'fallback'}` and cites the first user seq.
|
|
94
|
+
*
|
|
95
|
+
* Each user message and the answer that replied to it share one turn, which
|
|
96
|
+
* is the shape DSH itself writes (see the comment on `closeTurn`); the
|
|
97
|
+
* replayed surface is then the conversation as the user lived it.
|
|
98
|
+
* @param messages - `{role:'user'|'assistant', text}` in conversation order.
|
|
99
|
+
* @param title - session title (truncated to TITLE_MAX).
|
|
100
|
+
* @returns the event batch for one `handle.append`.
|
|
101
|
+
*/
|
|
102
|
+
export function buildImportEvents(messages, title) {
|
|
103
|
+
const events = []
|
|
104
|
+
let seq = 0
|
|
105
|
+
let turn = 0
|
|
106
|
+
let firstUserSeq = -1
|
|
107
|
+
const time = Date.now()
|
|
108
|
+
const push = (type, data, extra) => {
|
|
109
|
+
events.push({ type, seq: seq++, time, data, ...(extra ?? {}) })
|
|
110
|
+
}
|
|
111
|
+
// One turn per user message, with the answer that replied to it inside the
|
|
112
|
+
// same turn: DSH's own log reads
|
|
113
|
+
// turn/start → step/start → user/message → assistant/message → step/end → turn/end
|
|
114
|
+
// and a fold that expects that shape drops an answer that sits in a turn of
|
|
115
|
+
// its own (the symptom: only the first user message shows up).
|
|
116
|
+
const closeTurn = (userText, assistantText) => {
|
|
117
|
+
turn += 1
|
|
118
|
+
push('turn/start', { turn })
|
|
119
|
+
push('step/start', { turn, step: 1 })
|
|
120
|
+
if (userText !== null) {
|
|
121
|
+
if (firstUserSeq === -1) firstUserSeq = seq
|
|
122
|
+
push('user/message', {
|
|
123
|
+
content: [{ type: 'text', text: userText }],
|
|
124
|
+
source: { kind: 'user' },
|
|
125
|
+
role: 'user',
|
|
126
|
+
id: randomUUID(),
|
|
127
|
+
}, { surfaceOp: 'append' })
|
|
128
|
+
}
|
|
129
|
+
if (assistantText !== null) {
|
|
130
|
+
push('assistant/message', {
|
|
131
|
+
turn,
|
|
132
|
+
step: 1,
|
|
133
|
+
message: {
|
|
134
|
+
role: 'assistant',
|
|
135
|
+
content: [{ type: 'text', text: assistantText }],
|
|
136
|
+
source: { kind: 'model', provider: 'deepseek-web', model: 'deepseek-chat' },
|
|
137
|
+
id: randomUUID(),
|
|
138
|
+
},
|
|
139
|
+
stream: [],
|
|
140
|
+
}, { surfaceOp: 'append' })
|
|
141
|
+
}
|
|
142
|
+
push('step/end', { turn, step: 1 })
|
|
143
|
+
push('turn/end', { turn, reason: { kind: 'completed' } })
|
|
144
|
+
}
|
|
145
|
+
let pendingUser = null
|
|
146
|
+
for (const message of messages) {
|
|
147
|
+
if (message === null || typeof message !== 'object') continue
|
|
148
|
+
const text = typeof message.text === 'string' ? message.text : ''
|
|
149
|
+
if (text === '') continue
|
|
150
|
+
if (message.role === 'user') {
|
|
151
|
+
if (pendingUser !== null) closeTurn(pendingUser, null) // a question that never got an answer
|
|
152
|
+
pendingUser = text
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (message.role === 'assistant') {
|
|
156
|
+
closeTurn(pendingUser, text)
|
|
157
|
+
pendingUser = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (pendingUser !== null) closeTurn(pendingUser, null)
|
|
161
|
+
push('session/title', {
|
|
162
|
+
title: typeof title === 'string' && title !== '' ? title.slice(0, TITLE_MAX) : 'Imported DeepSeek conversation',
|
|
163
|
+
messageSeqs: firstUserSeq === -1 ? [] : [firstUserSeq],
|
|
164
|
+
source: { kind: 'fallback' },
|
|
165
|
+
})
|
|
166
|
+
// `session/end-seed` closes the seeded history and is the last event of a
|
|
167
|
+
// seeded log.
|
|
168
|
+
push('session/end-seed', {})
|
|
169
|
+
return events
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate a share-import request body.
|
|
174
|
+
* @param body - the parsed request body.
|
|
175
|
+
* @returns `{shareId, workspaceId}` or an error string.
|
|
176
|
+
*/
|
|
177
|
+
export function validateShareImportBody(body) {
|
|
178
|
+
if (body === null || typeof body !== 'object') return 'body must be an object'
|
|
179
|
+
if (typeof body.workspaceId !== 'string' || body.workspaceId === '') return 'workspaceId required'
|
|
180
|
+
const shareId = parseShareId(body.shareUrl ?? body.shareId)
|
|
181
|
+
if (shareId === null) return 'invalid share link'
|
|
182
|
+
return { shareId, workspaceId: body.workspaceId }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read a JSON file, tolerating absence and parse errors.
|
|
187
|
+
* @returns parsed value, or null.
|
|
188
|
+
*/
|
|
189
|
+
export function readJsonFile(path) {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Atomically write a JSON file (temp file + rename) so a crash never leaves a
|
|
199
|
+
* half-written file behind.
|
|
200
|
+
*/
|
|
201
|
+
/** Distinguishes concurrent scratch files. */
|
|
202
|
+
let ledgerWriteCounter = 0
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Write a JSON file atomically.
|
|
206
|
+
*
|
|
207
|
+
* The write stays synchronous on purpose: a failed write has to surface at
|
|
208
|
+
* the call site (the route turns it into a 500), not as a floating rejected
|
|
209
|
+
* promise. The temporary name carries the process id and a counter so two
|
|
210
|
+
* writers never share one scratch file.
|
|
211
|
+
* @param path - the target file.
|
|
212
|
+
* @param value - the JSON value to store.
|
|
213
|
+
*/
|
|
214
|
+
export function writeJsonFile(path, value) {
|
|
215
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
216
|
+
const temporary = `${path}.${process.pid}.${ledgerWriteCounter++}.tmp`
|
|
217
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
218
|
+
renameSync(temporary, path)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read the ledger, change it, and write it back — in one synchronous step.
|
|
223
|
+
*
|
|
224
|
+
* The import awaits the network and the disk between its first read and its
|
|
225
|
+
* write, so the file has to be re-read right before the write: otherwise two
|
|
226
|
+
* concurrent imports of different shares overwrite each other and one entry
|
|
227
|
+
* is lost (the same share could then be imported twice).
|
|
228
|
+
* @param change - receives the ledger, mutates it, and returns the new value.
|
|
229
|
+
* @returns the written ledger.
|
|
230
|
+
*/
|
|
231
|
+
export function updateLedger(change) {
|
|
232
|
+
const ledger = readJsonFile(ledgerFile()) ?? {}
|
|
233
|
+
const next = change(ledger) ?? ledger
|
|
234
|
+
writeJsonFile(ledgerFile(), next)
|
|
235
|
+
return next
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Send a JSON response. */
|
|
239
|
+
export function json(res, status, body) {
|
|
240
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
241
|
+
res.end(JSON.stringify(body))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @returns the parsed JSON body of a request, or null. */
|
|
245
|
+
export function readJsonBody(req) {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
let raw = ''
|
|
248
|
+
let overflow = false
|
|
249
|
+
req.on('data', (chunk) => {
|
|
250
|
+
if (overflow) return
|
|
251
|
+
raw += chunk
|
|
252
|
+
// A body bigger than any import request is either a mistake or an
|
|
253
|
+
// attack; either way it is not parsed.
|
|
254
|
+
if (raw.length > BODY_LIMIT) {
|
|
255
|
+
overflow = true
|
|
256
|
+
raw = ''
|
|
257
|
+
resolve(null)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
req.on('end', () => {
|
|
261
|
+
if (overflow) return
|
|
262
|
+
try { resolve(JSON.parse(raw)) } catch (error) { resolve(null) }
|
|
263
|
+
})
|
|
264
|
+
req.on('error', () => resolve(null))
|
|
265
|
+
req.on('aborted', () => resolve(null))
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read one raw share response into an import decision.
|
|
271
|
+
*
|
|
272
|
+
* The endpoint answers in three very different shapes and they must not be
|
|
273
|
+
* confused: a JSON body with the conversation, a JSON body with a non-zero
|
|
274
|
+
* `biz_code` (the share is gone), and — when the edge is not happy — an HTML
|
|
275
|
+
* error page (rate limit, blocked). The status is checked before the body is
|
|
276
|
+
* parsed, because the HTML page is not JSON at all.
|
|
277
|
+
* @param response - `{status, contentType, text}`.
|
|
278
|
+
* @returns `{ok: true, title, messages}` or `{ok: false, code}`.
|
|
279
|
+
*/
|
|
280
|
+
export function evaluateShareResponse(response) {
|
|
281
|
+
const status = typeof response?.status === 'number' ? response.status : 0
|
|
282
|
+
if (status === 429) return { ok: false, code: 'rate-limited' }
|
|
283
|
+
if (status >= 300 && status < 400) return { ok: false, code: 'bad-response' }
|
|
284
|
+
if (status !== 200) return { ok: false, code: 'bad-response' }
|
|
285
|
+
if (typeof response.contentType === 'string' && !response.contentType.includes('json')) {
|
|
286
|
+
return { ok: false, code: 'bad-response' }
|
|
287
|
+
}
|
|
288
|
+
let payload
|
|
289
|
+
try {
|
|
290
|
+
payload = JSON.parse(response.text)
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return { ok: false, code: 'bad-response' }
|
|
293
|
+
}
|
|
294
|
+
const data = payload !== null && typeof payload === 'object' ? payload.data : null
|
|
295
|
+
if (data === null || typeof data !== 'object' || data.biz_code !== 0) {
|
|
296
|
+
return { ok: false, code: 'not-found' }
|
|
297
|
+
}
|
|
298
|
+
const biz = data.biz_data !== null && typeof data.biz_data === 'object' ? data.biz_data : {}
|
|
299
|
+
const messages = mapShareMessages(biz.messages)
|
|
300
|
+
if (messages.length === 0) return { ok: false, code: 'empty' }
|
|
301
|
+
return { ok: true, title: shareTitle(biz.title), messages }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Fetch a shared conversation's content.
|
|
306
|
+
*
|
|
307
|
+
* The request is a direct (proxy-free) HTTPS call: this process does not use
|
|
308
|
+
* the system proxy, which is what makes the call work on a machine whose
|
|
309
|
+
* proxy exit the edge blocks. Only the fixed endpoint is ever contacted — the
|
|
310
|
+
* user's link contributes nothing but the validated id.
|
|
311
|
+
* @param shareId - the validated share id.
|
|
312
|
+
* @returns {Promise<{ok: true, title: string, messages: object[]} | {ok: false, code: string}>}
|
|
313
|
+
*/
|
|
314
|
+
export function fetchShareContent(shareId) {
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
let settled = false
|
|
317
|
+
const finish = (value) => {
|
|
318
|
+
if (settled) return
|
|
319
|
+
settled = true
|
|
320
|
+
clearTimeout(deadline)
|
|
321
|
+
resolve(value)
|
|
322
|
+
}
|
|
323
|
+
// A socket idle timeout only fires while nothing is flowing: an
|
|
324
|
+
// upstream that dies mid-body would otherwise leave the request
|
|
325
|
+
// pending forever, so the whole call carries its own deadline.
|
|
326
|
+
const deadline = setTimeout(() => {
|
|
327
|
+
try { request.destroy() } catch (error) { /* already gone */ }
|
|
328
|
+
finish({ ok: false, code: 'timeout' })
|
|
329
|
+
}, SHARE_TIMEOUT_MS)
|
|
330
|
+
const path = '/api/v0/share/content?share_id=' + encodeURIComponent(shareId)
|
|
331
|
+
const request = httpsRequest({
|
|
332
|
+
host: 'chat.deepseek.com',
|
|
333
|
+
port: 443,
|
|
334
|
+
path,
|
|
335
|
+
method: 'GET',
|
|
336
|
+
headers: {
|
|
337
|
+
'user-agent': BROWSER_UA,
|
|
338
|
+
accept: 'application/json',
|
|
339
|
+
'accept-encoding': 'identity',
|
|
340
|
+
},
|
|
341
|
+
}, (response) => {
|
|
342
|
+
const status = response.statusCode ?? 0
|
|
343
|
+
const contentType = String(response.headers['content-type'] ?? '')
|
|
344
|
+
if (status !== 200) {
|
|
345
|
+
response.resume()
|
|
346
|
+
finish(evaluateShareResponse({ status, contentType, text: '' }))
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const chunks = []
|
|
350
|
+
let size = 0
|
|
351
|
+
response.on('data', (chunk) => {
|
|
352
|
+
size += chunk.length
|
|
353
|
+
if (size > SHARE_BODY_LIMIT) {
|
|
354
|
+
request.destroy()
|
|
355
|
+
finish({ ok: false, code: 'too-large' })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
chunks.push(chunk)
|
|
359
|
+
})
|
|
360
|
+
response.on('end', () => {
|
|
361
|
+
finish(evaluateShareResponse({
|
|
362
|
+
status,
|
|
363
|
+
contentType,
|
|
364
|
+
text: Buffer.concat(chunks).toString('utf8'),
|
|
365
|
+
}))
|
|
366
|
+
})
|
|
367
|
+
// A connection that breaks mid-body never reaches 'end'.
|
|
368
|
+
response.on('aborted', () => finish({ ok: false, code: 'network' }))
|
|
369
|
+
response.on('error', () => finish({ ok: false, code: 'network' }))
|
|
370
|
+
response.on('close', () => finish({ ok: false, code: 'network' }))
|
|
371
|
+
})
|
|
372
|
+
request.setTimeout(SHARE_TIMEOUT_MS, () => {
|
|
373
|
+
request.destroy()
|
|
374
|
+
finish({ ok: false, code: 'timeout' })
|
|
375
|
+
})
|
|
376
|
+
request.on('error', () => finish({ ok: false, code: 'network' }))
|
|
377
|
+
request.end()
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Compare two dotted versions. Only the numeric parts decide the order; a
|
|
383
|
+
* `-prerelease` suffix is ignored (the footer compares released versions).
|
|
384
|
+
* @param a - left version, e.g. "0.2.0".
|
|
385
|
+
* @param b - right version, e.g. "0.2.1".
|
|
386
|
+
* @returns 1 when a > b, -1 when a < b, 0 when equal.
|
|
387
|
+
*/
|
|
388
|
+
export function compareVersions(a, b) {
|
|
389
|
+
const parts = (value) => String(value).split('-')[0].split('.').map((piece) => {
|
|
390
|
+
const parsed = Number.parseInt(piece, 10)
|
|
391
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
392
|
+
})
|
|
393
|
+
const left = parts(a)
|
|
394
|
+
const right = parts(b)
|
|
395
|
+
const count = Math.max(left.length, right.length)
|
|
396
|
+
for (let index = 0; index < count; index += 1) {
|
|
397
|
+
const x = left[index] ?? 0
|
|
398
|
+
const y = right[index] ?? 0
|
|
399
|
+
if (x > y) return 1
|
|
400
|
+
if (x < y) return -1
|
|
401
|
+
}
|
|
402
|
+
return 0
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Turn a registry `/latest` reply into an update verdict.
|
|
407
|
+
* @param response - `{status, text}` (a 404 means "never published").
|
|
408
|
+
* @param currentVersion - the version this host is running.
|
|
409
|
+
* @returns `{ok: true, current, latest, hasUpdate}` or `{ok: false, code}`.
|
|
410
|
+
*/
|
|
411
|
+
export function evaluateVersionResponse(response, currentVersion) {
|
|
412
|
+
const status = typeof response?.status === 'number' ? response.status : 0
|
|
413
|
+
if (status === 404) return { ok: false, code: 'not-published' }
|
|
414
|
+
if (status !== 200) return { ok: false, code: 'bad-response' }
|
|
415
|
+
let payload
|
|
416
|
+
try {
|
|
417
|
+
payload = JSON.parse(response.text)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return { ok: false, code: 'bad-response' }
|
|
420
|
+
}
|
|
421
|
+
const latest = payload !== null && typeof payload === 'object' && typeof payload.version === 'string'
|
|
422
|
+
? payload.version.trim()
|
|
423
|
+
: ''
|
|
424
|
+
if (latest === '' || !/^\d+\.\d+\.\d+/.test(latest)) return { ok: false, code: 'bad-response' }
|
|
425
|
+
return { ok: true, current: currentVersion, latest, hasUpdate: compareVersions(latest, currentVersion) > 0 }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Ask npm for the latest published version of this plugin.
|
|
430
|
+
*
|
|
431
|
+
* Like the share fetch this is a direct HTTPS call (no system proxy), so it
|
|
432
|
+
* works on a machine whose proxy exit the registry does not like.
|
|
433
|
+
* @returns {Promise<object>} the {@link evaluateVersionResponse} verdict.
|
|
434
|
+
*/
|
|
435
|
+
export function fetchLatestVersion() {
|
|
436
|
+
return new Promise((resolve) => {
|
|
437
|
+
let settled = false
|
|
438
|
+
const finish = (value) => {
|
|
439
|
+
if (settled) return
|
|
440
|
+
settled = true
|
|
441
|
+
clearTimeout(deadline)
|
|
442
|
+
resolve(value)
|
|
443
|
+
}
|
|
444
|
+
const deadline = setTimeout(() => {
|
|
445
|
+
try { request.destroy() } catch (error) { /* already gone */ }
|
|
446
|
+
finish({ ok: false, code: 'timeout' })
|
|
447
|
+
}, UPDATE_TIMEOUT_MS)
|
|
448
|
+
const request = httpsRequest({
|
|
449
|
+
host: 'registry.npmjs.org',
|
|
450
|
+
port: 443,
|
|
451
|
+
path: '/dsh-webseek/latest',
|
|
452
|
+
method: 'GET',
|
|
453
|
+
headers: {
|
|
454
|
+
'user-agent': BROWSER_UA,
|
|
455
|
+
accept: 'application/json',
|
|
456
|
+
'accept-encoding': 'identity',
|
|
457
|
+
},
|
|
458
|
+
}, (response) => {
|
|
459
|
+
const status = response.statusCode ?? 0
|
|
460
|
+
if (status !== 200 && status !== 404) {
|
|
461
|
+
response.resume()
|
|
462
|
+
finish(evaluateVersionResponse({ status, text: '' }, PLUGIN_VERSION))
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const chunks = []
|
|
466
|
+
let size = 0
|
|
467
|
+
response.on('data', (chunk) => {
|
|
468
|
+
size += chunk.length
|
|
469
|
+
if (size > SHARE_BODY_LIMIT) {
|
|
470
|
+
request.destroy()
|
|
471
|
+
finish({ ok: false, code: 'too-large' })
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
chunks.push(chunk)
|
|
475
|
+
})
|
|
476
|
+
response.on('end', () => {
|
|
477
|
+
finish(evaluateVersionResponse({
|
|
478
|
+
status,
|
|
479
|
+
text: Buffer.concat(chunks).toString('utf8'),
|
|
480
|
+
}, PLUGIN_VERSION))
|
|
481
|
+
})
|
|
482
|
+
response.on('aborted', () => finish({ ok: false, code: 'network' }))
|
|
483
|
+
response.on('error', () => finish({ ok: false, code: 'network' }))
|
|
484
|
+
response.on('close', () => finish({ ok: false, code: 'network' }))
|
|
485
|
+
})
|
|
486
|
+
request.setTimeout(UPDATE_TIMEOUT_MS, () => {
|
|
487
|
+
request.destroy()
|
|
488
|
+
finish({ ok: false, code: 'timeout' })
|
|
489
|
+
})
|
|
490
|
+
request.on('error', () => finish({ ok: false, code: 'network' }))
|
|
491
|
+
request.end()
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Write an imported conversation as a real, cold DSH session and attach it
|
|
497
|
+
* to the chosen workspace. Idempotent per ledger key.
|
|
498
|
+
* @param ctx - host context (session services are read from it).
|
|
499
|
+
* @param input - `{title, messages, workspaceId, ledgerKey}`.
|
|
500
|
+
* @returns {Promise<{already: boolean, sessionId: string, workspaceId: string, title: string}>}
|
|
501
|
+
*/
|
|
502
|
+
/** In-flight imports per ledger key: two parallel imports of the same share
|
|
503
|
+
* would race on the ledger file and lose one entry. */
|
|
504
|
+
const importsInFlight = new Map()
|
|
505
|
+
|
|
506
|
+
export async function createImportedSession(ctx, input) {
|
|
507
|
+
const key = input?.ledgerKey ?? ''
|
|
508
|
+
if (key === '') throw new Error('ledgerKey required')
|
|
509
|
+
const running = importsInFlight.get(key)
|
|
510
|
+
if (running !== undefined) return running
|
|
511
|
+
const promise = createImportedSessionNow(ctx, input)
|
|
512
|
+
importsInFlight.set(key, promise)
|
|
513
|
+
try {
|
|
514
|
+
return await promise
|
|
515
|
+
} finally {
|
|
516
|
+
importsInFlight.delete(key)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The actual import: write the session, then record it.
|
|
522
|
+
* @param ctx - host context.
|
|
523
|
+
* @param input - `{title, messages, workspaceId, ledgerKey}`.
|
|
524
|
+
* @returns {Promise<{already: boolean, sessionId: string, workspaceId: string, title: string}>}
|
|
525
|
+
*/
|
|
526
|
+
async function createImportedSessionNow(ctx, { title, messages, workspaceId, ledgerKey }) {
|
|
527
|
+
const ledger = readJsonFile(ledgerFile()) ?? {}
|
|
528
|
+
const existing = ledger[ledgerKey]
|
|
529
|
+
// Only a finished import counts as done: a crashed run leaves a `pending`
|
|
530
|
+
// reservation behind, and that one has to be redone rather than answered
|
|
531
|
+
// with a session that was never written.
|
|
532
|
+
// A ledger entry written before the `state` field existed counts as done.
|
|
533
|
+
if (existing !== null && typeof existing === 'object'
|
|
534
|
+
&& typeof existing.sessionId === 'string' && (existing.state ?? 'done') === 'done') {
|
|
535
|
+
return {
|
|
536
|
+
already: true,
|
|
537
|
+
sessionId: existing.sessionId,
|
|
538
|
+
workspaceId: typeof existing.workspaceId === 'string' ? existing.workspaceId : workspaceId,
|
|
539
|
+
title: typeof existing.title === 'string' ? existing.title : title,
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const persistence = ctx.get('sessionPersistence')
|
|
544
|
+
const workspaces = ctx.get('workspaceRegistry')
|
|
545
|
+
if (persistence === undefined || workspaces === undefined) {
|
|
546
|
+
throw new Error('session services unavailable')
|
|
547
|
+
}
|
|
548
|
+
const workspace = workspaces.get(workspaceId)
|
|
549
|
+
if (workspace === undefined) throw new Error('unknown workspace')
|
|
550
|
+
|
|
551
|
+
// DSH names its sessions `session-<uuid>`; the tree and the client's
|
|
552
|
+
// openSession() both key on that exact id, so a bare uuid would create a
|
|
553
|
+
// session nobody can open.
|
|
554
|
+
const sessionId = `session-${randomUUID()}`
|
|
555
|
+
const events = buildImportEvents(messages, title)
|
|
556
|
+
const header = {
|
|
557
|
+
version: SESSION_FORMAT_VERSION,
|
|
558
|
+
id: sessionId,
|
|
559
|
+
createdAt: Date.now(),
|
|
560
|
+
cwd: workspace.path,
|
|
561
|
+
isSeeded: false,
|
|
562
|
+
// `agentPreset` stays unset on purpose: it is optional, and a session
|
|
563
|
+
// without one falls back to the deployment default, while a hard-coded
|
|
564
|
+
// preset id would break the session as soon as that preset is renamed.
|
|
565
|
+
}
|
|
566
|
+
// Reserve the ledger key before the session exists: a second import that
|
|
567
|
+
// arrives while this one is still writing must not build a duplicate, and a
|
|
568
|
+
// failed import must not leave the key behind.
|
|
569
|
+
updateLedger((book) => {
|
|
570
|
+
book[ledgerKey] = { sessionId, title, workspaceId, state: 'pending', importedAt: new Date().toISOString() }
|
|
571
|
+
})
|
|
572
|
+
try {
|
|
573
|
+
const handle = await persistence.create(header)
|
|
574
|
+
try {
|
|
575
|
+
await handle.append(events)
|
|
576
|
+
await handle.flush()
|
|
577
|
+
} finally {
|
|
578
|
+
await handle.close()
|
|
579
|
+
}
|
|
580
|
+
await workspace.attachSession(sessionId)
|
|
581
|
+
} catch (error) {
|
|
582
|
+
// The write failed: drop the reservation so a retry can try again. The
|
|
583
|
+
// half-written log (if any) is not attached to the workspace, so the tree
|
|
584
|
+
// does not show it.
|
|
585
|
+
updateLedger((book) => { delete book[ledgerKey] })
|
|
586
|
+
throw error
|
|
587
|
+
}
|
|
588
|
+
// Seed the projection cache so the tree row renders immediately: listing
|
|
589
|
+
// reads rows straight from the cache (zero I/O), so a session with no cached
|
|
590
|
+
// projections is invisible in the workspace tree until something folds its
|
|
591
|
+
// log. Folding our own events here is the only moment we hold them; later
|
|
592
|
+
// reads can seed from the log themselves.
|
|
593
|
+
const projectionCache = ctx.get('sessionProjectionCache')
|
|
594
|
+
if (projectionCache !== undefined) {
|
|
595
|
+
try {
|
|
596
|
+
projectionCache.coldSnapshot(header, 0, events)
|
|
597
|
+
} catch (error) {
|
|
598
|
+
ctx.logger?.warn?.(`webseek: projection seed for "${sessionId}" failed (tree shows it after next cold read): ${String(error)}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
updateLedger((book) => {
|
|
602
|
+
book[ledgerKey] = { sessionId, title, workspaceId, state: 'done', importedAt: new Date().toISOString() }
|
|
603
|
+
})
|
|
604
|
+
return { already: false, sessionId, workspaceId, title }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Plugin activation.
|
|
609
|
+
* @param ctx - host context.
|
|
610
|
+
*/
|
|
611
|
+
export function apply(ctx) {
|
|
612
|
+
ctx.effect(() => ctx.webServer.register({
|
|
613
|
+
kind: 'exact',
|
|
614
|
+
path: '/api/dsh-webseek/workspaces',
|
|
615
|
+
handler: (req, res) => {
|
|
616
|
+
const workspaces = ctx.get('workspaceRegistry')
|
|
617
|
+
if (workspaces === undefined) return json(res, 200, { ok: true, workspaces: [] })
|
|
618
|
+
json(res, 200, {
|
|
619
|
+
ok: true,
|
|
620
|
+
workspaces: workspaces.list().map((workspace) => ({
|
|
621
|
+
id: workspace.id, title: workspace.title, path: workspace.path,
|
|
622
|
+
})),
|
|
623
|
+
})
|
|
624
|
+
},
|
|
625
|
+
}), 'webseek: workspaces route')
|
|
626
|
+
|
|
627
|
+
// Ledger read-back for the UI (already-imported marks).
|
|
628
|
+
ctx.effect(() => ctx.webServer.register({
|
|
629
|
+
kind: 'exact',
|
|
630
|
+
path: '/api/dsh-webseek/ledger',
|
|
631
|
+
handler: (req, res) => {
|
|
632
|
+
json(res, 200, { ok: true, ledger: readJsonFile(ledgerFile()) ?? {} })
|
|
633
|
+
},
|
|
634
|
+
}), 'webseek: ledger route')
|
|
635
|
+
|
|
636
|
+
// Import a shared conversation: validate the link, fetch it, write it as a
|
|
637
|
+
// cold session in the chosen workspace.
|
|
638
|
+
ctx.effect(() => ctx.webServer.register({
|
|
639
|
+
kind: 'exact',
|
|
640
|
+
path: '/api/dsh-webseek/share-import',
|
|
641
|
+
handler: async (req, res) => {
|
|
642
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'POST only' })
|
|
643
|
+
const parsed = validateShareImportBody(await readJsonBody(req))
|
|
644
|
+
if (typeof parsed === 'string') return json(res, 400, { ok: false, error: parsed })
|
|
645
|
+
const { shareId, workspaceId } = parsed
|
|
646
|
+
const workspaces = ctx.get('workspaceRegistry')
|
|
647
|
+
if (workspaces === undefined) return json(res, 503, { ok: false, error: 'workspaceRegistry unavailable' })
|
|
648
|
+
const workspace = workspaces.get(workspaceId)
|
|
649
|
+
if (workspace === undefined) {
|
|
650
|
+
return json(res, 400, { ok: false, error: 'unknown workspace' })
|
|
651
|
+
}
|
|
652
|
+
const fetched = await fetchShareContent(shareId)
|
|
653
|
+
if (fetched.ok !== true) {
|
|
654
|
+
const status = fetched.code === 'rate-limited' ? 429 : 502
|
|
655
|
+
return json(res, status, { ok: false, error: fetched.code })
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const created = await createImportedSession(ctx, {
|
|
659
|
+
title: fetched.title,
|
|
660
|
+
messages: fetched.messages,
|
|
661
|
+
workspaceId,
|
|
662
|
+
ledgerKey: 'share:' + shareId,
|
|
663
|
+
})
|
|
664
|
+
// The session may live in another workspace (an earlier import); the
|
|
665
|
+
// title has to name where it actually is.
|
|
666
|
+
const home = created.workspaceId === workspaceId
|
|
667
|
+
? workspace
|
|
668
|
+
: workspaces.get(created.workspaceId)
|
|
669
|
+
json(res, 200, { ok: true, shareId, workspaceTitle: home?.title ?? workspace.title, ...created })
|
|
670
|
+
} catch (error) {
|
|
671
|
+
// The details (paths, ids) stay in the host log; the browser gets a
|
|
672
|
+
// stable code it can explain.
|
|
673
|
+
ctx.logger?.warn?.(`webseek: share import failed for "${shareId}": ${String(error)}`)
|
|
674
|
+
json(res, 500, { ok: false, error: 'import-failed' })
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
}), 'webseek: share import route')
|
|
678
|
+
|
|
679
|
+
// Latest published version, so the dialog footer can say "up to date" or
|
|
680
|
+
// name the update. Always 200: the body's `ok` explains what happened.
|
|
681
|
+
ctx.effect(() => ctx.webServer.register({
|
|
682
|
+
kind: 'exact',
|
|
683
|
+
path: '/api/dsh-webseek/update-check',
|
|
684
|
+
handler: async (req, res) => {
|
|
685
|
+
json(res, 200, await fetchLatestVersion())
|
|
686
|
+
},
|
|
687
|
+
}), 'webseek: update-check route')
|
|
688
|
+
}
|