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/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
+ }