dsh-workspace-plus 0.6.0 → 0.7.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.
@@ -0,0 +1,636 @@
1
+ /**
2
+ * dsh-workspace-plus / sessionlog — host half.
3
+ *
4
+ * DSH ships an "在使用官方模型 API 时上传 Session Log" preference whose whole
5
+ * delivery path hangs off the **official DeepSeek adapter**: the host plugin
6
+ * `@deepseek-ai/dsh-session-log-deepseek` registers a `dsh_session_log` field on
7
+ * the `deepseekLlmApiExtensions` registry, and only `dsh-llm-deepseek`'s
8
+ * `/messages` request ever carries it. A user whose active model provider is
9
+ * something else therefore uploads nothing at all — there is no request to
10
+ * attach the field to.
11
+ *
12
+ * This sub-feature reproduces that behaviour and closes the gap. It has two
13
+ * independent channels, both gated by one switch that ships **OFF**:
14
+ *
15
+ * 1. **OTLP session-log channel (primary).** It observes the canonical session
16
+ * stream (`session/created`, `session/event`) and hands one record per
17
+ * event to its own byte-bounded channel created through the public
18
+ * `ctx.otel.createSessionLogReporter(...)`. That transport is owned by
19
+ * DSH, carries batching/retry/shutdown, and — crucially — does not care
20
+ * which model adapter is in play. It posts to the DeepSeek-operated
21
+ * collector the base profile already uses for its own session telemetry.
22
+ * 2. **`dsh_session_log` request field (parity fallback).** When the official
23
+ * DeepSeek adapter is the one performing the request *and* no other plugin
24
+ * already owns that field (in a shipped profile the built-in package does),
25
+ * this half contributes the field in the shipped v1 wire format. It is a
26
+ * fallback for compositions that drop the built-in package, not a second
27
+ * copy of the primary channel.
28
+ *
29
+ * Nothing here imports anything but Node builtins and sibling files, and the
30
+ * whole thing reads no credentials: the collector endpoint is a public ingest
31
+ * URL, and the only identity attached is the Harness home's anonymous UUID.
32
+ *
33
+ * @module dsh-workspace-plus/sessionlog
34
+ */
35
+
36
+ import { readFileSync } from 'node:fs'
37
+ import { dirname, join } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+ import {
40
+ ENDPOINT_ENV,
41
+ MAX_REQUEST_BYTES_CEILING,
42
+ MAX_SHUTDOWN_TIMEOUT_MILLIS,
43
+ SETTING_KEYS,
44
+ anonymousUserId,
45
+ coerceBoolean,
46
+ coerceCount,
47
+ composeSettings,
48
+ defaultSettings,
49
+ normalizeEndpoint,
50
+ normalizeSettings,
51
+ readStore,
52
+ resolveEndpoint,
53
+ storePath,
54
+ telemetryKillSwitch,
55
+ uploadBlockedBy,
56
+ writeStore,
57
+ } from './settings.js'
58
+ import {
59
+ REQUEST_FIELD,
60
+ SCOPE_NAME,
61
+ buildRequestField,
62
+ sessionLogRecord,
63
+ } from './wire.js'
64
+ import { detectCredentialShapes } from './redact.js'
65
+
66
+ /** Loader row module name; must stay in step with the `./sessionlog` export. */
67
+ export const name = 'dsh-workspace-plus/sessionlog'
68
+
69
+ /** Same-origin JSON route the browser half reads and writes. */
70
+ export const API_PATH = '/dsh-sessionlog/api'
71
+
72
+ /** Request-body ceiling, matching the plugin's other two routes. */
73
+ const MAX_BODY_BYTES = 64 * 1024
74
+
75
+ /** `service.name` reported to the collector, matching DSH's own telemetry. */
76
+ export const SERVICE_NAME = 'deepseek-harness'
77
+
78
+ /** Where the request-field channel currently stands, for the settings page. */
79
+ export const REQUEST_FIELD_STATES = Object.freeze(['active', 'taken', 'unavailable', 'disabled'])
80
+
81
+ /**
82
+ * Read this package's version. `files` publishes `lib/`, so the root manifest
83
+ * is always two directories up in an installed bundle.
84
+ *
85
+ * @returns the version string, or `'0.0.0'` when it cannot be read.
86
+ */
87
+ export function pluginVersion() {
88
+ try {
89
+ const here = dirname(fileURLToPath(import.meta.url))
90
+ const manifest = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'))
91
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : '0.0.0'
92
+ } catch {
93
+ return '0.0.0'
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Host entry point.
99
+ *
100
+ * @param ctx - Cordis host context.
101
+ * @param config - the loader row's `config:` block (deployment defaults).
102
+ */
103
+ export function apply(ctx, config) {
104
+ const file = storePath()
105
+ const base = normalizeSettings(config)
106
+ const loaded = readStore(file)
107
+ let overrides = loaded.overrides
108
+ let accepted = loaded.accepted
109
+ if (loaded.warning !== null) ctx.logger?.warn?.(`[${name}] ${loaded.warning}`)
110
+
111
+ /**
112
+ * Effective settings, cached. `report()` runs on the session-event firehose,
113
+ * so recomposing (and re-parsing the endpoint URL) per event would put
114
+ * avoidable work on the append path; `refresh()` is the one invalidation
115
+ * point, called at load and after every accepted settings write.
116
+ */
117
+ let effective = composeSettings(base, overrides)
118
+ let effectiveEndpoint = resolveEndpoint(effective, process.env)
119
+
120
+ /** @returns the cached effective settings. */
121
+ const current = () => effective
122
+
123
+ /** Recompute the cached settings after a write. */
124
+ function refresh() {
125
+ effective = composeSettings(base, overrides)
126
+ effectiveEndpoint = resolveEndpoint(effective, process.env)
127
+ }
128
+
129
+ /** Per-session high-water mark of what this process has already handed over. */
130
+ const cursor = new WeakMap()
131
+
132
+ /** Observable counters surfaced on the settings page. */
133
+ const stats = {
134
+ startedAt: Date.now(),
135
+ events: 0,
136
+ sessions: 0,
137
+ lastEventAt: null,
138
+ lastAcceptAt: null,
139
+ lastError: null,
140
+ lastErrorAt: null,
141
+ /** Credential shapes scrubbed out of outbound events, and when the last one was. */
142
+ redactions: 0,
143
+ lastRedactionAt: null,
144
+ }
145
+
146
+ const recordError = (message) => {
147
+ stats.lastError = message
148
+ stats.lastErrorAt = Date.now()
149
+ }
150
+
151
+ // --- Channel 1: the provider-independent OTLP session-log channel ---------
152
+
153
+ /** The `otel` service, once the composition provides it. */
154
+ let otelScope = null
155
+ /** The channel this plugin owns; `null` while uploading is off or unavailable. */
156
+ let reporter = null
157
+ /** Endpoint the live channel was built for, so a change rebuilds it. */
158
+ let reporterEndpoint = null
159
+ /** Where the request-field channel stands; the page renders this. */
160
+ let requestFieldState = 'disabled'
161
+ /** Why the request-field channel is not active, when it is not. */
162
+ let requestFieldError = null
163
+
164
+ /**
165
+ * Drain and release the current channel. The channel is detached first so a
166
+ * concurrent `ensureReporter` can never hand work to a channel that is on its
167
+ * way out.
168
+ */
169
+ async function shutdownReporter() {
170
+ const active = reporter
171
+ reporter = null
172
+ reporterEndpoint = null
173
+ if (active === null) return
174
+ const deadline = current().shutdownTimeoutMillis
175
+ let timer
176
+ try {
177
+ await Promise.race([
178
+ Promise.resolve(active.shutdown()),
179
+ new Promise((_resolve, reject) => {
180
+ timer = setTimeout(() => {
181
+ try {
182
+ active.stopPending?.()
183
+ } catch {
184
+ /* the channel is being abandoned; nothing useful to do with this */
185
+ }
186
+ reject(new Error(`provider shutdown exceeded ${deadline}ms`))
187
+ }, deadline)
188
+ }),
189
+ ])
190
+ } catch (error) {
191
+ const message = String((error && error.message) || error)
192
+ ctx.logger?.warn?.(`[${name}] upload channel shutdown: ${message}`)
193
+ } finally {
194
+ if (timer !== undefined) clearTimeout(timer)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Materialize the channel for the current settings, reusing it when neither
200
+ * enablement nor endpoint changed.
201
+ *
202
+ * @returns the channel to report into, or `null` when uploading is off or
203
+ * `ctx.otel` is not mounted.
204
+ */
205
+ function ensureReporter() {
206
+ const settings = current()
207
+ if (uploadBlockedBy(settings) !== null) {
208
+ void shutdownReporter()
209
+ return null
210
+ }
211
+ if (otelScope === null || typeof otelScope.otel?.createSessionLogReporter !== 'function') return null
212
+ const endpoint = effectiveEndpoint
213
+ if (reporter !== null && reporterEndpoint === endpoint) return reporter
214
+ void shutdownReporter()
215
+ try {
216
+ reporter = otelScope.otel.createSessionLogReporter({
217
+ scope: { name: SCOPE_NAME, version: pluginVersion() },
218
+ exporter: { url: endpoint, compression: 'gzip', timeoutMillis: 1000 },
219
+ processor: {
220
+ scheduledDelayMillis: 10000,
221
+ maxQueueSize: 2048,
222
+ maxExportBatchSize: 2048,
223
+ exportTimeoutMillis: 1500,
224
+ },
225
+ maxRequestBytes: settings.maxRequestBytes,
226
+ resourceAttributes: {
227
+ 'service.name': SERVICE_NAME,
228
+ 'service.version': typeof process.env.DSH_CLIENT_VERSION === 'string' && process.env.DSH_CLIENT_VERSION !== '' ? process.env.DSH_CLIENT_VERSION : 'unknown',
229
+ 'user.id': anonymousUserId(),
230
+ 'dsh.plugin': name,
231
+ },
232
+ onFailure: (message, error) => {
233
+ recordError(`${String(message)}${error === undefined ? '' : `: ${String((error && error.message) || error)}`}`)
234
+ ctx.logger?.warn?.(`[${name}] ${message}`, error)
235
+ },
236
+ })
237
+ reporterEndpoint = endpoint
238
+ ctx.logger?.info?.(`[${name}] 会话日志上传已开启 → ${endpoint}`)
239
+ } catch (error) {
240
+ const message = String((error && error.message) || error)
241
+ recordError(`创建上传通道失败:${message}`)
242
+ ctx.logger?.warn?.(`[${name}] 创建上传通道失败:${message}`)
243
+ reporter = null
244
+ reporterEndpoint = null
245
+ return null
246
+ }
247
+ return reporter
248
+ }
249
+
250
+ /**
251
+ * Hand one canonical event to the live channel, advancing the in-process
252
+ * cursor only after the handoff succeeded.
253
+ *
254
+ * @param session - the owning Session.
255
+ * @param event - the canonical event.
256
+ */
257
+ function report(session, event) {
258
+ if (event === null || typeof event !== 'object' || typeof event.type !== 'string') return
259
+ if (uploadBlockedBy(current()) !== null) return
260
+ const active = ensureReporter()
261
+ if (active === null) return
262
+ const seq = Number(event.seq)
263
+ if (!Number.isSafeInteger(seq)) return
264
+ const seen = cursor.get(session)
265
+ if (typeof seen === 'number' && seq <= seen) return
266
+ try {
267
+ // Redaction happens inside the wire layer, so no outbound path can skip it.
268
+ // Counting here is the only way the settings page can prove it is working.
269
+ const shapes = detectCredentialShapes(event.data)
270
+ if (shapes.length > 0) {
271
+ stats.redactions += shapes.length
272
+ stats.lastRedactionAt = Date.now()
273
+ }
274
+ active.reportSessionLog(sessionLogRecord(session, event))
275
+ cursor.set(session, seq)
276
+ stats.events += 1
277
+ stats.lastEventAt = Date.now()
278
+ } catch (error) {
279
+ recordError(String((error && error.message) || error))
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Replay the canonical log from one offset through the same path a live event
285
+ * takes, so ordering and de-duplication stay identical.
286
+ *
287
+ * @param session - the Session to replay.
288
+ * @param fromSeq - inclusive first sequence to send.
289
+ */
290
+ function replay(session, fromSeq) {
291
+ if (typeof session.snapshotEvents !== 'function') return
292
+ for (const event of session.snapshotEvents(fromSeq)) report(session, event)
293
+ }
294
+
295
+ /**
296
+ * Start following one Session. A Session this process has never seen begins
297
+ * either at its first lifecycle event (the default) or at sequence 0 when
298
+ * history upload is on.
299
+ *
300
+ * @param session - the live Session.
301
+ */
302
+ function adoptLive(session) {
303
+ if (cursor.has(session)) return
304
+ const settings = current()
305
+ if (uploadBlockedBy(settings) !== null) return
306
+ const first = Number(session.firstLifecycleSeq ?? 0)
307
+ const start = settings.includeHistory === true ? 0 : first
308
+ cursor.set(session, start - 1)
309
+ stats.sessions += 1
310
+ replay(session, start)
311
+ }
312
+
313
+ /**
314
+ * Sweep the Sessions that are already live. This is what makes turning the
315
+ * switch on mid-run behave like a restart: sessions created before the plugin
316
+ * (or before the switch) are picked up instead of silently skipped.
317
+ */
318
+ function adoptExisting() {
319
+ const settings = current()
320
+ if (uploadBlockedBy(settings) !== null) return
321
+ if (typeof ctx.sessions?.list !== 'function') return
322
+ for (const session of ctx.sessions.list()) {
323
+ if (cursor.has(session)) continue
324
+ const start = settings.includeHistory === true ? 0 : Number(session.seq)
325
+ cursor.set(session, start - 1)
326
+ stats.sessions += 1
327
+ replay(session, start)
328
+ }
329
+ }
330
+
331
+ /** Re-evaluate enablement after a settings write. */
332
+ function afterSettingsChange() {
333
+ if (uploadBlockedBy(current()) === null) {
334
+ ensureReporter()
335
+ adoptExisting()
336
+ return
337
+ }
338
+ void shutdownReporter()
339
+ }
340
+
341
+ ctx.inject(['otel'], (scope) => {
342
+ otelScope = scope
343
+ afterSettingsChange()
344
+ })
345
+
346
+ // --- Channel 2: the `dsh_session_log` request field -----------------------
347
+
348
+ /**
349
+ * Highest sequence the provider already accepted for this exact Session
350
+ * format generation, or `-1` before any accepted request. A mismatch in
351
+ * format generation discards the watermark rather than resending a range the
352
+ * provider would reinterpret.
353
+ *
354
+ * @param session - the Session the request carries.
355
+ * @returns the exclusive lower bound for the next upload.
356
+ */
357
+ function watermarkFor(session) {
358
+ const entry = accepted[String(session.id)]
359
+ if (entry === undefined) return -1
360
+ if (entry.formatVersion !== Number(session.header?.version ?? 0)) return -1
361
+ return entry.throughSeq
362
+ }
363
+
364
+ /**
365
+ * Prepare the request field for one official DeepSeek request. Returning
366
+ * `undefined` means "this request carries no field", which is the honest
367
+ * answer whenever uploading is off, the Session is not live, or nothing new
368
+ * fits the byte ceiling.
369
+ *
370
+ * @param request - the adapter's prepared request facts.
371
+ * @returns `{ value, accept }`, or `undefined`.
372
+ */
373
+ function prepareRequestField(request) {
374
+ const settings = current()
375
+ if (uploadBlockedBy(settings) !== null || settings.requestField !== true) return undefined
376
+ if (request === null || typeof request !== 'object' || request.sessionId === undefined) return undefined
377
+ const session = ctx.sessions?.get?.(request.sessionId)
378
+ if (session === undefined || session === null) return undefined
379
+ const built = buildRequestField({
380
+ session,
381
+ afterSeq: watermarkFor(session),
382
+ maxBytes: settings.maxRequestBytes,
383
+ })
384
+ if (built === undefined) return undefined
385
+ return {
386
+ value: built.value,
387
+ accept: () => {
388
+ accepted = {
389
+ ...accepted,
390
+ [String(session.id)]: {
391
+ formatVersion: Number(session.header?.version ?? 0),
392
+ throughSeq: built.throughSeq,
393
+ },
394
+ }
395
+ const written = writeStore(file, { overrides, accepted })
396
+ if (!written.ok) {
397
+ recordError(`保存接受水位失败:${written.message}`)
398
+ ctx.logger?.warn?.(`[${name}] 保存接受水位失败:${written.message}`)
399
+ }
400
+ stats.lastAcceptAt = Date.now()
401
+ },
402
+ }
403
+ }
404
+
405
+ ctx.inject(['deepseekLlmApiExtensions'], (scope) => {
406
+ const registry = scope.deepseekLlmApiExtensions
407
+ if (registry === undefined || typeof registry.register !== 'function') {
408
+ requestFieldState = 'unavailable'
409
+ requestFieldError = '当前组合里没有 deepseekLlmApiExtensions 服务'
410
+ return
411
+ }
412
+ // The shipped `@deepseek-ai/dsh-session-log-deepseek` registers this exact
413
+ // field at load time, and the registry throws on a duplicate. Detect the
414
+ // owner first so a shipped profile never turns this fallback into a load
415
+ // failure; the primary OTLP channel covers those profiles anyway.
416
+ if (registry.providers?.has?.(REQUEST_FIELD) === true) {
417
+ requestFieldState = 'taken'
418
+ requestFieldError = `${REQUEST_FIELD} 已由其它插件提供(通常是 @deepseek-ai/dsh-session-log-deepseek);请求字段这一路自动跳过`
419
+ ctx.logger?.info?.(`[${name}] ${requestFieldError}`)
420
+ return
421
+ }
422
+ try {
423
+ scope.effect(
424
+ () => registry.register(REQUEST_FIELD, { prepare: prepareRequestField }),
425
+ `${name}: register(${REQUEST_FIELD})`,
426
+ )
427
+ requestFieldState = 'active'
428
+ requestFieldError = null
429
+ } catch (error) {
430
+ requestFieldState = 'unavailable'
431
+ requestFieldError = String((error && error.message) || error)
432
+ ctx.logger?.warn?.(`[${name}] 注册 ${REQUEST_FIELD} 失败:${requestFieldError}`)
433
+ }
434
+ })
435
+
436
+ // --- Follow the canonical session stream ---------------------------------
437
+
438
+ ctx.inject(['sessions'], (scope) => {
439
+ scope.on('session/created', (session) => {
440
+ try {
441
+ adoptLive(session)
442
+ } catch (error) {
443
+ recordError(String((error && error.message) || error))
444
+ }
445
+ })
446
+ scope.on('session/event', (session, event) => {
447
+ // Every listener runs inside Cordis' stop-on-throw emit, so a failure
448
+ // here would starve later subscribers; report() already contains its own.
449
+ try {
450
+ report(session, event)
451
+ } catch (error) {
452
+ recordError(String((error && error.message) || error))
453
+ }
454
+ })
455
+ scope.on('session/disposed', (session) => {
456
+ cursor.delete(session)
457
+ })
458
+ adoptExisting()
459
+ })
460
+
461
+ // --- The browser half's same-origin JSON endpoint ------------------------
462
+
463
+ /**
464
+ * Everything the settings page renders.
465
+ *
466
+ * @returns a JSON-safe snapshot.
467
+ */
468
+ const snapshot = () => {
469
+ const settings = current()
470
+ const fromEnv = typeof process.env[ENDPOINT_ENV] === 'string' ? process.env[ENDPOINT_ENV].trim() : ''
471
+ return {
472
+ ok: true,
473
+ settings: { ...settings },
474
+ defaults: defaultSettings(),
475
+ effectiveEndpoint,
476
+ endpointSource: settings.endpoint !== '' ? 'settings' : fromEnv !== '' ? 'environment' : 'default',
477
+ // A process-level opt-out outranks this page's switch; the page says so
478
+ // rather than pretending the switch it just flipped did nothing.
479
+ killSwitch: telemetryKillSwitch(),
480
+ blockedReason: uploadBlockedBy(settings),
481
+ channel: {
482
+ otlpAvailable: otelScope !== null,
483
+ reporting: reporter !== null,
484
+ requestField: requestFieldState,
485
+ requestFieldError,
486
+ },
487
+ storePath: file,
488
+ stats: { ...stats },
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Validate and apply one settings patch. Every value is checked before
494
+ * anything is written, and a value equal to the bundle default drops its
495
+ * override instead of pinning it — otherwise a deployment that shipped
496
+ * `enabled: true` could never be switched off from the page.
497
+ *
498
+ * @param patch - the requested keys.
499
+ * @returns `{ ok, message, settings }`.
500
+ */
501
+ function applyPatch(patch) {
502
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
503
+ return { ok: false, message: 'patch 必须是对象', settings: current() }
504
+ }
505
+ const next = { ...overrides }
506
+ for (const [key, value] of Object.entries(patch)) {
507
+ if (!SETTING_KEYS.includes(key)) return { ok: false, message: `未知设置项:${key}`, settings: current() }
508
+ if (key === 'endpoint') {
509
+ const normalized = normalizeEndpoint(value)
510
+ if (normalized.warning !== null) return { ok: false, message: normalized.warning, settings: current() }
511
+ if (normalized.value === base.endpoint) delete next[key]
512
+ else next[key] = normalized.value
513
+ continue
514
+ }
515
+ if (key === 'maxRequestBytes' || key === 'shutdownTimeoutMillis') {
516
+ const number = typeof value === 'number' ? value : Number(String(value ?? '').trim())
517
+ if (!Number.isFinite(number) || number <= 0) {
518
+ return { ok: false, message: `${key} 必须是正数`, settings: current() }
519
+ }
520
+ const ceiling = key === 'maxRequestBytes' ? MAX_REQUEST_BYTES_CEILING : MAX_SHUTDOWN_TIMEOUT_MILLIS
521
+ const coerced = coerceCount(value, NaN, ceiling)
522
+ if (!Number.isFinite(coerced)) return { ok: false, message: `${key} 不是合法数值`, settings: current() }
523
+ if (coerced === base[key]) delete next[key]
524
+ else next[key] = coerced
525
+ continue
526
+ }
527
+ const coerced = coerceBoolean(value, undefined)
528
+ if (typeof coerced !== 'boolean') {
529
+ return { ok: false, message: `${key} 必须是布尔值`, settings: current() }
530
+ }
531
+ if (coerced === base[key]) delete next[key]
532
+ else next[key] = coerced
533
+ }
534
+ overrides = next
535
+ const written = writeStore(file, { overrides, accepted })
536
+ if (!written.ok) return { ok: false, message: written.message, settings: current() }
537
+ refresh()
538
+ afterSettingsChange()
539
+ return { ok: true, message: '设置已保存', settings: current() }
540
+ }
541
+
542
+ ctx.inject(['webServer'], (webCtx) => {
543
+ webCtx.effect(
544
+ () =>
545
+ webCtx.webServer.register({
546
+ kind: 'exact',
547
+ path: API_PATH,
548
+ handler: async (req, res) => {
549
+ const connection = ctx.get('connection')
550
+ const admission = connection?.admit?.(req)
551
+ if (admission !== undefined && 'rejection' in admission) {
552
+ res.writeHead(admission.rejection)
553
+ res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
554
+ return
555
+ }
556
+ const send = (status, payload) => {
557
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
558
+ res.end(JSON.stringify(payload))
559
+ }
560
+ try {
561
+ if (req.method === 'GET') {
562
+ send(200, snapshot())
563
+ return
564
+ }
565
+ if (req.method !== 'POST') {
566
+ send(405, { ok: false, message: 'method not allowed' })
567
+ return
568
+ }
569
+ const chunks = []
570
+ let bytes = 0
571
+ for await (const chunk of req) {
572
+ bytes += chunk.length
573
+ if (bytes > MAX_BODY_BYTES) {
574
+ // Drain before answering: a 413 that abandons the body can be
575
+ // swallowed by the socket teardown instead of reaching the page.
576
+ for await (const rest of req) void rest
577
+ send(413, { ok: false, message: 'request body too large', ...snapshot() })
578
+ return
579
+ }
580
+ chunks.push(chunk)
581
+ }
582
+ let payload
583
+ try {
584
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
585
+ } catch {
586
+ send(400, { ok: false, message: 'body is not JSON', ...snapshot() })
587
+ return
588
+ }
589
+ if (payload?.op === 'patch') {
590
+ const result = applyPatch(payload.patch)
591
+ send(result.ok ? 200 : 400, { ...snapshot(), ok: result.ok, message: result.message })
592
+ return
593
+ }
594
+ if (payload?.op === 'reset') {
595
+ overrides = {}
596
+ const written = writeStore(file, { overrides, accepted })
597
+ if (!written.ok) {
598
+ send(500, { ok: false, message: written.message, ...snapshot() })
599
+ return
600
+ }
601
+ refresh()
602
+ afterSettingsChange()
603
+ send(200, { ...snapshot(), message: '已恢复默认' })
604
+ return
605
+ }
606
+ send(400, { ok: false, message: `unknown op: ${String(payload?.op)}`, ...snapshot() })
607
+ } catch (error) {
608
+ const message = String((error && error.message) || error)
609
+ recordError(message)
610
+ ctx.logger?.warn?.(`[${name}] ${message}`)
611
+ send(500, { ok: false, message })
612
+ }
613
+ },
614
+ }),
615
+ `${name}: ${API_PATH} route`,
616
+ )
617
+ })
618
+
619
+ // --- Teardown -------------------------------------------------------------
620
+
621
+ // The channel is owned by this fiber: unloading the row drains queued records
622
+ // up to the configured deadline instead of dropping them outright. The async
623
+ // disposer is awaited by Cordis, exactly like the shipped session-telemetry
624
+ // backend's own teardown.
625
+ ctx.effect(
626
+ () => () => shutdownReporter(),
627
+ `${name}: upload channel teardown`,
628
+ )
629
+
630
+ const settings = current()
631
+ ctx.logger?.info?.(
632
+ `[${name}] 已加载:上传${settings.enabled === true ? '已开启' : '默认关闭'}(${resolveEndpoint(settings, process.env)})`,
633
+ )
634
+ }
635
+
636
+ export { EVENT_BODY, EVENT_NAME, REQUEST_FIELD, SCOPE_NAME } from './wire.js'
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session Log (upload)",
4
+ "description": "Upload session logs to DeepSeek to help improve DeepSeek models and products. Off by default; once enabled it works regardless of which model API is in use."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "会话日志(上传)",
4
+ "description": "把会话日志上传给 DeepSeek(帮助改进 DeepSeek 模型与产品)。默认关闭;开启后与使用哪个模型 API 无关,换用第三方模型 API 时同样生效。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "dsh-workspace-plus-sessionlog",
3
+ "description": "在使用官方模型 API 时上传 Session Log(帮助改进 DeepSeek 模型与产品)的子模块版本:默认关闭,除官方适配器的请求字段外,还通过 DSH 自带的 OTLP 通道把会话日志送到 DeepSeek collector —— 因此换用第三方模型 API 时同样生效。",
4
+ "icon": "./icon.svg",
5
+ "type": "module"
6
+ }