@followbin/dsh-selection 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/README.zh-CN.md +152 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1070 -0
  6. package/cordis.patch.yml +4 -0
  7. package/icon.svg +1 -0
  8. package/index.js +41 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +90 -4
  12. package/src/host/files.mjs +29 -0
  13. package/src/host/http.mjs +153 -0
  14. package/src/host/llm.mjs +194 -0
  15. package/src/host/log.mjs +40 -0
  16. package/src/host/service.mjs +334 -0
  17. package/src/host/settings.mjs +282 -0
  18. package/src/host/skills.mjs +88 -0
  19. package/src/host/supervisor.mjs +126 -0
  20. package/src/host/usage.mjs +128 -0
  21. package/src/shared/icons.mjs +66 -0
  22. package/src/shared/open-with-system.mjs +15 -0
  23. package/src/shared/paths.mjs +7 -0
  24. package/src/shared/protocol.mjs +4 -0
  25. package/src/worker/controller.mjs +274 -0
  26. package/src/worker/main.mjs +147 -0
  27. package/src/worker/native-window.mjs +319 -0
  28. package/src/worker/open-url.mjs +20 -0
  29. package/src/worker/overlay.mjs +266 -0
  30. package/src/worker/page.mjs +14 -0
  31. package/src/worker/placement.mjs +58 -0
  32. package/src/worker/speech.mjs +136 -0
  33. package/ui/markdown.js +122 -0
  34. package/ui/toolbar.css +162 -0
  35. package/ui/toolbar.html +54 -0
  36. package/ui/toolbar.js +552 -0
  37. package/vendor/selection-hook/LICENSE +21 -0
  38. package/vendor/selection-hook/index.d.ts +517 -0
  39. package/vendor/selection-hook/index.js +662 -0
  40. package/vendor/selection-hook/package.json +10 -0
  41. package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
  42. package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
  43. package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
  44. package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: dsh-selection
3
+ name: '@followbin/dsh-selection'
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none"><path d="M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5" stroke="url(#dsh-selection-frame)" stroke-width="2.4" stroke-linecap="round"/><path d="M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z" fill="url(#dsh-selection-spark)" stroke="none"/><defs><linearGradient id="dsh-selection-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient><linearGradient id="dsh-selection-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs></svg>
package/index.js ADDED
@@ -0,0 +1,41 @@
1
+ // DSH plugin entry (Host side). Starts the selection service and mounts its HTTP API.
2
+ // The settings page lives in client.js; the toolbar runs in a worker process (src/worker).
3
+ import { join } from 'node:path'
4
+ import { createHttpHandler, ROUTE } from './src/host/http.mjs'
5
+ import { createLogger } from './src/host/log.mjs'
6
+ import { createSelectionService } from './src/host/service.mjs'
7
+ import { dataDirectory } from './src/shared/paths.mjs'
8
+
9
+ export const name = 'dsh-selection'
10
+ export const inject = ['webServer', 'connection']
11
+
12
+ /**
13
+ * @param {any} ctx Cordis Host context.
14
+ * @param {{ promptForPermission?: boolean, force?: boolean }} [config] Row config from cordis.patch.yml.
15
+ * `force` starts the toolbar outside DSH Desktop (only for a web Host on the user's own machine).
16
+ */
17
+ export function apply(ctx, config = {}) {
18
+ const dataDir = dataDirectory()
19
+ const log = createLogger(join(dataDir, 'host.log'))
20
+ // The toolbar must appear on the user's screen, so it only runs where the Host shares their desktop.
21
+ const desktop = Boolean(process.versions.electron) || config.force === true
22
+ const service = createSelectionService({
23
+ // Looked up per request: the llm service may load after this plugin, or be swapped at runtime.
24
+ getLlm: () => ctx.get('llm'),
25
+ log,
26
+ dataDir,
27
+ desktop,
28
+ promptForPermission: config.promptForPermission !== false,
29
+ })
30
+ log('info', 'plugin started', { platform: process.platform, desktop })
31
+ ctx.effect(() => () => service.dispose(), 'dsh-selection: service')
32
+ ctx.on('llm/adapters-updated', () => service.invalidateModels())
33
+ ctx.effect(
34
+ () => ctx.webServer.register({
35
+ kind: 'prefix',
36
+ path: ROUTE,
37
+ handler: createHttpHandler(service, { log, connection: ctx.connection }),
38
+ }),
39
+ 'dsh-selection: http routes',
40
+ )
41
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Selection assistant",
4
+ "description": "Select text in any app and let DSH translate, summarize, explain, answer or read it aloud."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "划词助手",
4
+ "description": "在任意应用里选中文字,用 DSH 翻译、总结、解释、追问或朗读。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,92 @@
1
1
  {
2
2
  "name": "@followbin/dsh-selection",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Select text in any app and let DSH translate, summarize, explain, answer or read it aloud.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "selection",
10
+ "translate",
11
+ "popclip"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "followbin",
15
+ "homepage": "https://github.com/followbin/dsh-selection#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/followbin/dsh-selection.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/followbin/dsh-selection/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./index.js",
28
+ "icon": "./icon.svg",
29
+ "exports": {
30
+ ".": "./index.js",
31
+ "./client": "./client.js",
32
+ "./locale/*.json": "./locale/*.json",
33
+ "./icon": "./icon.svg",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "client.js",
39
+ "cordis.patch.yml",
40
+ "icon.svg",
41
+ "locale/*.json",
42
+ "src",
43
+ "ui",
44
+ "vendor",
45
+ "README.md",
46
+ "README.zh-CN.md",
47
+ "LICENSE",
48
+ "THIRD_PARTY_NOTICES.md"
49
+ ],
50
+ "scripts": {
51
+ "test": "node --test test/*.test.mjs",
52
+ "selftest": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/selftest.mjs",
53
+ "dev": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/dev-host.mjs",
54
+ "vendor:selection-hook": "node scripts/vendor-selection-hook.mjs",
55
+ "screenshots": "ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" scripts/screenshots.mjs"
56
+ },
57
+ "dependencies": {
58
+ "@webviewjs/webview": "0.6.0"
59
+ },
60
+ "optionalDependencies": {
61
+ "@koromix/koffi-darwin-arm64": "3.3.6",
62
+ "@koromix/koffi-darwin-x64": "3.3.6",
63
+ "@koromix/koffi-win32-arm64": "3.3.6",
64
+ "@koromix/koffi-win32-x64": "3.3.6"
65
+ },
66
+ "engines": {
67
+ "node": ">=24"
68
+ },
69
+ "dsh": {
70
+ "bundle": {
71
+ "patch": "./cordis.patch.yml",
72
+ "platforms": [
73
+ "darwin",
74
+ "win32"
75
+ ]
76
+ },
77
+ "client": {
78
+ "platform": "web",
79
+ "immediately": true,
80
+ "inject": [
81
+ "@deepseek-ai/dsh-client-ui-layout",
82
+ "@deepseek-ai/dsh-client-ui-sidebar",
83
+ "@deepseek-ai/dsh-client-locale"
84
+ ]
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "jsdom": "26.1.0",
89
+ "react": "19.1.10",
90
+ "react-dom": "19.1.10"
91
+ }
92
+ }
@@ -0,0 +1,29 @@
1
+ // Writes a file so that readers see either the old or the new content, never half of it.
2
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ export function writeFileAtomic(file, text) {
6
+ mkdirSync(dirname(file), { recursive: true })
7
+ const temporary = `${file}.${process.pid}.tmp`
8
+ writeFileSync(temporary, text, { mode: 0o600 })
9
+ try {
10
+ replaceFile(temporary, file)
11
+ } catch (error) {
12
+ try { unlinkSync(temporary) } catch { /* already gone */ }
13
+ throw error
14
+ }
15
+ }
16
+
17
+ // Windows refuses to replace a file another process has open (antivirus, indexer, sync clients),
18
+ // usually for a few milliseconds.
19
+ function replaceFile(source, target) {
20
+ for (let attempt = 1; ; attempt += 1) {
21
+ try {
22
+ renameSync(source, target)
23
+ return
24
+ } catch (error) {
25
+ if (process.platform !== 'win32' || attempt === 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) throw error
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt)
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,153 @@
1
+ // HTTP API for the settings page, mounted on DSH's web server under /dsh-selection.
2
+ // DSH's `connection` service is the gate: it fences Host/Origin (cross-site calls, DNS rebinding) and
3
+ // requires the signed-in browser session, the same check DSH's own routes run. Writes must be JSON,
4
+ // which keeps simple cross-site form posts out even before that check.
5
+ import { BadRequest } from './service.mjs'
6
+
7
+ export const ROUTE = '/dsh-selection'
8
+ const MAX_BODY_BYTES = 256 * 1024
9
+ const KEEPALIVE_MS = 25_000
10
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
11
+
12
+ /**
13
+ * @param {ReturnType<import('./service.mjs').createSelectionService>} service
14
+ * @param {{
15
+ * log: (level: string, message: string, fields?: object) => void,
16
+ * connection?: { requestRejection: (req: import('node:http').IncomingMessage) => number | undefined },
17
+ * }} options Without `connection` (tests, the dev runner) only loopback same-origin requests pass.
18
+ */
19
+ export function createHttpHandler(service, { log, connection }) {
20
+ return async function handle(req, res) {
21
+ const url = new URL(req.url ?? '/', 'http://localhost')
22
+ const path = url.pathname.slice(ROUTE.length) || '/'
23
+ const status = connection ? connection.requestRejection(req) : localRejection(req)
24
+ if (status !== undefined) return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
25
+ if (browserRejection(req)) return sendJson(res, 403, { error: 'forbidden' })
26
+ const writes = req.method === 'POST' || req.method === 'PUT'
27
+ if (writes && !isJson(req)) return sendJson(res, 415, { error: 'expected application/json' })
28
+ try {
29
+ switch (`${req.method} ${path}`) {
30
+ case 'GET /api/state':
31
+ return sendJson(res, 200, service.getState())
32
+ case 'GET /api/models':
33
+ return sendJson(res, 200, await service.listModels())
34
+ case 'GET /api/events':
35
+ return openEventStream(req, res, service)
36
+ case 'PUT /api/settings':
37
+ return sendJson(res, 200, { settings: service.saveSettings(await readJson(req)) })
38
+ case 'POST /api/settings/reset':
39
+ return sendJson(res, 200, { settings: service.resetSettings() })
40
+ case 'POST /api/models/test':
41
+ return sendJson(res, 200, await service.testModel())
42
+ case 'POST /api/skills/test':
43
+ return sendJson(res, 200, await service.testSkill(await readJson(req)))
44
+ case 'GET /api/usage':
45
+ return sendJson(res, 200, service.getUsage())
46
+ case 'POST /api/usage/reset':
47
+ return sendJson(res, 200, service.resetUsage())
48
+ case 'POST /api/worker/restart':
49
+ service.restartWorker()
50
+ return sendJson(res, 200, { ok: true })
51
+ case 'POST /api/permission/open':
52
+ service.openPermissionSettings()
53
+ return sendJson(res, 200, { ok: true })
54
+ default:
55
+ return sendJson(res, 404, { error: 'not found' })
56
+ }
57
+ } catch (error) {
58
+ if (error instanceof BadRequest) return sendJson(res, 400, { error: error.message })
59
+ log('error', 'request failed', { method: req.method, path, error: String(error?.message ?? error) })
60
+ return sendJson(res, 500, { error: 'internal error' })
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Fetch metadata and Origin say the request comes from another site. Cheap, and true for any Host. */
66
+ export function browserRejection(req) {
67
+ const site = req.headers['sec-fetch-site']
68
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return true
69
+ const origin = req.headers.origin
70
+ if (typeof origin !== 'string' || origin === 'null') return false
71
+ try {
72
+ return new URL(origin).host !== req.headers.host
73
+ } catch {
74
+ return true
75
+ }
76
+ }
77
+
78
+ /** Fallback gate when DSH's connection service is absent: loopback only. */
79
+ export function localRejection(req) {
80
+ const host = typeof req.headers.host === 'string' ? req.headers.host : ''
81
+ const hostname = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0].toLowerCase()
82
+ return LOOPBACK_HOSTS.has(hostname) ? undefined : 403
83
+ }
84
+
85
+ function isJson(req) {
86
+ return String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')
87
+ }
88
+
89
+ function readJson(req) {
90
+ return new Promise((resolve, reject) => {
91
+ const chunks = []
92
+ let size = 0
93
+ req.on('data', (chunk) => {
94
+ size += chunk.length
95
+ if (size > MAX_BODY_BYTES) {
96
+ reject(new BadRequest('request body too large'))
97
+ req.destroy()
98
+ return
99
+ }
100
+ chunks.push(chunk)
101
+ })
102
+ req.on('end', () => {
103
+ try {
104
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'))
105
+ } catch {
106
+ reject(new BadRequest('invalid JSON'))
107
+ }
108
+ })
109
+ req.on('error', reject)
110
+ })
111
+ }
112
+
113
+ function sendJson(res, status, value) {
114
+ const body = JSON.stringify(value)
115
+ res.writeHead(status, {
116
+ 'content-type': 'application/json; charset=utf-8',
117
+ 'cache-control': 'no-store',
118
+ 'content-length': Buffer.byteLength(body),
119
+ })
120
+ res.end(body)
121
+ }
122
+
123
+ /**
124
+ * Server-sent events: `status`, `settings` and `navigate` (open the settings page). Every connection
125
+ * starts with the current status and settings, so a page that reconnects never edits stale values.
126
+ */
127
+ function openEventStream(req, res, service) {
128
+ res.writeHead(200, {
129
+ 'content-type': 'text/event-stream; charset=utf-8',
130
+ 'cache-control': 'no-store',
131
+ connection: 'keep-alive',
132
+ })
133
+ let unsubscribe = () => {}
134
+ const keepalive = setInterval(() => res.write(': keepalive\n\n'), KEEPALIVE_MS)
135
+ const close = () => {
136
+ clearInterval(keepalive)
137
+ unsubscribe()
138
+ }
139
+ const write = (event, data) => {
140
+ if (event === 'closing') {
141
+ close()
142
+ res.end()
143
+ return
144
+ }
145
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
146
+ }
147
+ const { status, settings } = service.getState()
148
+ write('status', status)
149
+ write('settings', settings)
150
+ unsubscribe = service.subscribe(write)
151
+ req.on('close', close)
152
+ res.on('error', close)
153
+ }
@@ -0,0 +1,194 @@
1
+ // Calls models through DSH's own `llm` service, so requests use whatever account or API key the user
2
+ // already configured in DSH. Each skill click is one request with no tools and thinking off; Ask DSH
3
+ // carries the earlier turns of its panel in that request.
4
+
5
+ const PREFERRED_ROUTES = [
6
+ ['deepseek-account', 'deepseek-flash'],
7
+ ['deepseek-official', 'deepseek-flash'],
8
+ ]
9
+ const MAX_OUTPUT_TOKENS = 4096
10
+ // Silence limits rather than a total limit: a long, healthy answer keeps streaming as long as it needs.
11
+ const FIRST_TOKEN_TIMEOUT_MS = 60_000
12
+ const IDLE_TIMEOUT_MS = 45_000
13
+ const ROUTE_CACHE_MS = 5 * 60_000
14
+
15
+ /** @typedef {{ provider: string, providerName: string, model: string, modelName: string }} Route */
16
+
17
+ export class ModelError extends Error {
18
+ /**
19
+ * @param {'NO_LLM' | 'NO_MODEL' | 'MODEL_FAILED' | 'EMPTY' | 'TIMEOUT'} code Stable code the UI translates.
20
+ * @param {string} [detail] Provider message, shown as a secondary line.
21
+ */
22
+ constructor(code, detail = '') {
23
+ super(detail || code)
24
+ this.code = code
25
+ this.detail = detail
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {{ getLlm: () => any, log?: (level: string, message: string, fields?: object) => void }} options
31
+ */
32
+ export function createModelClient({ getLlm, log }) {
33
+ let cache = null
34
+ let listing = null
35
+ // Bumped by invalidate(), so a listing already under way when DSH's providers change is not cached.
36
+ let generation = 0
37
+ // Routes that reject an "off" reasoning level; without this every click would prepare the call twice.
38
+ const withoutOff = new Set()
39
+
40
+ function llmService() {
41
+ const llm = getLlm()
42
+ if (!llm) throw new ModelError('NO_LLM')
43
+ return llm
44
+ }
45
+
46
+ /** Every provider/model pair DSH can currently call. Concurrent callers share one listing. */
47
+ function listRoutes({ fresh = false } = {}) {
48
+ if (!fresh && cache && Date.now() - cache.at < ROUTE_CACHE_MS) return Promise.resolve(cache.routes)
49
+ if (!fresh && listing) return listing
50
+ const current = readRoutes(generation).finally(() => {
51
+ if (listing === current) listing = null
52
+ })
53
+ listing = current
54
+ return current
55
+ }
56
+
57
+ async function readRoutes(started) {
58
+ const llm = llmService()
59
+ const providers = llm.listProviders()
60
+ // Each provider looks up its credentials and may fetch its model list, so ask them all at once.
61
+ const results = await Promise.allSettled(providers.map((provider) => llm.listModels(provider.id)))
62
+ const routes = []
63
+ let complete = true
64
+ results.forEach((result, index) => {
65
+ const provider = providers[index]
66
+ if (result.status === 'rejected') {
67
+ complete = false
68
+ log?.('warn', 'listing models failed', { provider: provider.id, error: String(result.reason?.message ?? result.reason) })
69
+ return
70
+ }
71
+ for (const model of result.value ?? []) {
72
+ routes.push({
73
+ provider: provider.id,
74
+ providerName: provider.displayName ?? provider.name ?? provider.label ?? provider.id,
75
+ model: model.id,
76
+ modelName: model.displayName ?? model.name ?? model.id,
77
+ })
78
+ }
79
+ })
80
+ // An empty or partial list is often minutes from being fixed (a sign-in, a provider hiccup);
81
+ // caching it would keep answering NO_MODEL, or a fallback route, after that.
82
+ if (started === generation) cache = complete && routes.length ? { at: Date.now(), routes } : null
83
+ return routes
84
+ }
85
+
86
+ /** The configured route if it still exists, otherwise DeepSeek Flash, otherwise the first route. */
87
+ async function resolveRoute(settings) {
88
+ const routes = await listRoutes()
89
+ if (routes.length === 0) throw new ModelError('NO_MODEL')
90
+ const { provider, model } = settings.model
91
+ const pick = (p, m) => routes.find((route) => route.provider === p && route.model === m)
92
+ if (provider && model && pick(provider, model)) return pick(provider, model)
93
+ for (const [p, m] of PREFERRED_ROUTES) if (pick(p, m)) return pick(p, m)
94
+ return routes[0]
95
+ }
96
+
97
+ /**
98
+ * Streams the reply text.
99
+ * @param {{
100
+ * settings: import('./settings.mjs').Settings,
101
+ * prompt: (route: Route) => { system: string, user: string },
102
+ * signal: AbortSignal,
103
+ * onUsage?: (usage: object) => void,
104
+ * }} request `prompt` runs once the route is known, so the request can name the model answering it.
105
+ * `onUsage` receives DSH's token counts for the request, also when it fails or is cancelled.
106
+ * @returns {AsyncGenerator<string>}
107
+ */
108
+ async function* stream({ settings, prompt, signal, onUsage }) {
109
+ const llm = llmService()
110
+ const silence = new AbortController()
111
+ let timer = setTimeout(() => silence.abort(), FIRST_TOKEN_TIMEOUT_MS)
112
+ const combined = AbortSignal.any([signal, silence.signal])
113
+ const failed = (error) => {
114
+ if (signal.aborted) return signal.reason ?? new DOMException('Aborted', 'AbortError')
115
+ if (silence.signal.aborted) return new ModelError('TIMEOUT')
116
+ return error instanceof ModelError ? error : new ModelError('MODEL_FAILED', String(error?.message ?? error))
117
+ }
118
+ let usage = null
119
+ try {
120
+ const route = await abortable(resolveRoute(settings), combined)
121
+ const { system, user } = prompt(route)
122
+ const prepare = (effort) => llm.prepareCall({
123
+ provider: route.provider,
124
+ model: route.model,
125
+ maxTokens: MAX_OUTPUT_TOKENS,
126
+ ...effort ? { reasoningEffort: effort } : {},
127
+ }, combined)
128
+ const key = `${route.provider}\u0000${route.model}`
129
+ let prepared
130
+ if (withoutOff.has(key)) prepared = await prepare(null)
131
+ else {
132
+ try {
133
+ prepared = await prepare('off')
134
+ } catch (error) {
135
+ if (combined.aborted) throw error
136
+ // Routes without an "off" reasoning level reject it; their own default is the next best choice.
137
+ prepared = await prepare(null)
138
+ if (error?.code === 'UNSUPPORTED_REASONING_EFFORT') withoutOff.add(key)
139
+ log?.('info', 'reasoning effort off unavailable, using route default', { provider: route.provider, model: route.model, error: String(error?.message ?? error) })
140
+ }
141
+ }
142
+ const messages = [{ role: 'user', content: [{ type: 'text', text: user }] }]
143
+ let produced = false
144
+ for await (const chunk of prepared.stream({ ...prepared.config, system, messages, signal: combined })) {
145
+ clearTimeout(timer)
146
+ timer = setTimeout(() => silence.abort(), IDLE_TIMEOUT_MS)
147
+ if (chunk.type === 'text-delta' && chunk.text) {
148
+ produced = true
149
+ yield chunk.text
150
+ } else if (chunk.type === 'usage') {
151
+ usage = chunk.usage
152
+ } else if (chunk.type === 'finish') {
153
+ if (chunk.reason?.kind === 'error') throw new ModelError('MODEL_FAILED', chunk.reason.failure?.message ?? '')
154
+ if (chunk.reason?.kind === 'aborted') throw new DOMException('Aborted', 'AbortError')
155
+ }
156
+ }
157
+ if (!produced) throw new ModelError('EMPTY')
158
+ } catch (error) {
159
+ throw failed(error)
160
+ } finally {
161
+ clearTimeout(timer)
162
+ if (usage) {
163
+ try {
164
+ onUsage?.(usage)
165
+ } catch (error) {
166
+ log?.('warn', 'recording usage failed', { error: String(error?.message ?? error) })
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ /** Forgets what was learned about the routes, after DSH's providers or the chosen model change. */
173
+ function invalidate() {
174
+ generation += 1
175
+ cache = null
176
+ listing = null
177
+ withoutOff.clear()
178
+ }
179
+
180
+ return { listRoutes, resolveRoute, stream, invalidate }
181
+ }
182
+
183
+ /** Rejects as soon as the signal aborts, even if the work itself cannot be cancelled. */
184
+ function abortable(promise, signal) {
185
+ if (signal.aborted) return Promise.reject(signal.reason)
186
+ return new Promise((resolve, reject) => {
187
+ const onAbort = () => reject(signal.reason)
188
+ signal.addEventListener('abort', onAbort, { once: true })
189
+ promise.then(
190
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
191
+ (error) => { signal.removeEventListener('abort', onAbort); reject(error) },
192
+ )
193
+ })
194
+ }
@@ -0,0 +1,40 @@
1
+ // Small JSON-lines log under the plugin's data directory. It never records selected text or model
2
+ // output, only events, counts and errors, so it is safe to attach to bug reports.
3
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+
6
+ const MAX_BYTES = 1024 * 1024
7
+
8
+ /**
9
+ * @param {string} file
10
+ * @param {{ echo?: boolean }} [options] Also print to stderr (useful when running outside DSH).
11
+ * @returns {(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: object) => void}
12
+ */
13
+ export function createLogger(file, { echo = false } = {}) {
14
+ let ready = false
15
+ return (level, message, fields = {}) => {
16
+ const record = { time: new Date().toISOString(), level, message }
17
+ // A field never replaces the record's own time, level or message.
18
+ for (const [key, value] of Object.entries(fields)) record[key in record ? `_${key}` : key] = value
19
+ const line = JSON.stringify(record)
20
+ if (echo) console.error(`[dsh-selection] ${line}`)
21
+ try {
22
+ if (!ready) {
23
+ mkdirSync(dirname(file), { recursive: true })
24
+ ready = true
25
+ }
26
+ if (statSize(file) > MAX_BYTES) renameSync(file, `${file}.1`)
27
+ appendFileSync(file, `${line}\n`, { mode: 0o600 })
28
+ } catch {
29
+ // Logging must never take the plugin down.
30
+ }
31
+ }
32
+ }
33
+
34
+ function statSize(file) {
35
+ try {
36
+ return statSync(file).size
37
+ } catch {
38
+ return 0
39
+ }
40
+ }