@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,334 @@
1
+ // The Host half: owns settings, the worker process and model calls, and answers the settings page.
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { CUSTOM_SKILL_ICONS, ICONS } from '../shared/icons.mjs'
5
+ import { openWithSystem } from '../shared/open-with-system.mjs'
6
+ import { dataDirectory } from '../shared/paths.mjs'
7
+ import { ModelError, createModelClient } from './llm.mjs'
8
+ import {
9
+ BUILTIN_SKILLS, LANGUAGES, LIMITS, SEARCH_ENGINES,
10
+ createSettingsStore, defaultSettings, enabledSkills, findSkill, searchTemplate,
11
+ } from './settings.mjs'
12
+ import { buildRequest } from './skills.mjs'
13
+ import { startWorker } from './supervisor.mjs'
14
+ import { createUsageStore } from './usage.mjs'
15
+
16
+ const WORKER_PATH = fileURLToPath(new URL('../worker/main.mjs', import.meta.url))
17
+ const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])
18
+ const RECENT_APPS = 12
19
+ const ACCESSIBILITY_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
20
+
21
+ export class BadRequest extends Error {}
22
+
23
+ /**
24
+ * @param {{
25
+ * getLlm: () => any,
26
+ * log: (level: string, message: string, fields?: object) => void,
27
+ * dataDir?: string,
28
+ * desktop: boolean,
29
+ * promptForPermission?: boolean,
30
+ * platform?: string,
31
+ * startWorker?: typeof startWorker,
32
+ * openExternal?: (url: string) => void,
33
+ * }} options `desktop` is false when the Host is not on the user's machine (e.g. a remote web Host).
34
+ * Tests replace `platform`, `startWorker` and `openExternal`.
35
+ */
36
+ export function createSelectionService({
37
+ getLlm, log, dataDir = dataDirectory(), desktop, promptForPermission = true,
38
+ platform = process.platform, startWorker: launchWorker = startWorker, openExternal = (url) => openWithSystem(url, log),
39
+ }) {
40
+ const settings = createSettingsStore({ file: join(dataDir, 'settings.json'), log })
41
+ const model = createModelClient({ getLlm, log })
42
+ const usage = createUsageStore({ file: join(dataDir, 'usage.json'), log })
43
+ const subscribers = new Set()
44
+ const runs = new Map()
45
+ const recentApps = []
46
+ const status = {
47
+ platform,
48
+ supported: desktop && SUPPORTED_PLATFORMS.has(platform),
49
+ worker: 'stopped',
50
+ permission: platform === 'darwin' ? 'unknown' : 'granted',
51
+ hook: false,
52
+ }
53
+
54
+ const worker = status.supported
55
+ ? launchWorker({
56
+ workerPath: WORKER_PATH,
57
+ args: promptForPermission ? [] : ['--no-prompt'],
58
+ log,
59
+ onMessage: handleWorkerMessage,
60
+ onState: ({ state }) => {
61
+ // Answers in flight belong to the panel of the process that just went away.
62
+ if (state !== 'starting' && state !== 'running') abortRuns()
63
+ setStatus({ worker: state })
64
+ },
65
+ })
66
+ : null
67
+ if (!status.supported) log('info', 'selection toolbar not started', { platform, desktop })
68
+
69
+ let modelChoice = JSON.stringify(settings.get().model)
70
+ const unsubscribe = settings.onChange((next) => {
71
+ worker?.send({ type: 'config', config: workerConfig(next) })
72
+ if (JSON.stringify(next.model) !== modelChoice) {
73
+ modelChoice = JSON.stringify(next.model)
74
+ model.invalidate()
75
+ }
76
+ emit('settings', next)
77
+ })
78
+
79
+ function abortRuns() {
80
+ for (const controller of runs.values()) controller.abort()
81
+ runs.clear()
82
+ }
83
+
84
+ function recordUsage(skill, route, counts) {
85
+ usage.record({ skill, route, usage: counts })
86
+ emit('usage', usage.summary())
87
+ }
88
+
89
+ function emit(event, data) {
90
+ for (const subscriber of subscribers) subscriber(event, data)
91
+ }
92
+
93
+ function setStatus(patch) {
94
+ Object.assign(status, patch)
95
+ emit('status', status)
96
+ }
97
+
98
+ /** What the worker needs: no prompts or model details, only what to draw and how to behave. */
99
+ function workerConfig(current) {
100
+ return {
101
+ enabled: current.enabled,
102
+ trigger: current.trigger,
103
+ toolbarSize: current.toolbarSize,
104
+ toolbarStyle: current.toolbarStyle,
105
+ skills: enabledSkills(current),
106
+ disabledApps: current.disabledApps,
107
+ clipboardFallback: current.clipboardFallback,
108
+ maxTextLength: current.maxTextLength,
109
+ searchTemplate: searchTemplate(current),
110
+ translateTarget: current.translateTarget,
111
+ languages: LANGUAGES.map(({ code, name }) => ({ code, name })),
112
+ }
113
+ }
114
+
115
+ function handleWorkerMessage(message) {
116
+ switch (message?.type) {
117
+ case 'hello':
118
+ worker?.send({ type: 'config', config: workerConfig(settings.get()) })
119
+ break
120
+ case 'ready':
121
+ setStatus({ hook: Boolean(message.hookStarted) })
122
+ break
123
+ case 'permission':
124
+ setStatus({ permission: message.trusted ? 'granted' : 'denied' })
125
+ break
126
+ case 'selection':
127
+ rememberApp(message.app)
128
+ break
129
+ case 'run':
130
+ void runSkill(message)
131
+ break
132
+ case 'cancel':
133
+ runs.get(message.requestId)?.abort()
134
+ break
135
+ case 'toolbar-style':
136
+ if (message.style === 'full' || message.style === 'mini') {
137
+ try {
138
+ settings.update((current) => ({ ...current, toolbarStyle: message.style }))
139
+ } catch (error) {
140
+ log('error', 'saving settings failed', { error: String(error?.message ?? error) })
141
+ }
142
+ }
143
+ break
144
+ case 'disable-app':
145
+ if (typeof message.app === 'string' && message.app) {
146
+ try {
147
+ const saved = settings.update((current) => ({ ...current, disabledApps: [...current.disabledApps, message.app] }))
148
+ if (saved.disabledApps.includes(message.app)) log('info', 'app disabled from toolbar', { app: message.app })
149
+ else log('warn', 'app not disabled: the list is full', { app: message.app, limit: LIMITS.disabledApps })
150
+ } catch (error) {
151
+ log('error', 'saving settings failed', { error: String(error?.message ?? error) })
152
+ }
153
+ }
154
+ break
155
+ case 'open-settings':
156
+ openSettingsPage()
157
+ break
158
+ case 'log':
159
+ log(message.level ?? 'info', `worker: ${message.message}`, message.fields ?? {})
160
+ break
161
+ }
162
+ }
163
+
164
+ function rememberApp(app) {
165
+ if (typeof app !== 'string' || !app) return
166
+ const index = recentApps.indexOf(app)
167
+ if (index !== -1) recentApps.splice(index, 1)
168
+ recentApps.unshift(app)
169
+ recentApps.length = Math.min(recentApps.length, RECENT_APPS)
170
+ }
171
+
172
+ async function runSkill({ requestId, skillId, text, language, question, history }) {
173
+ const current = settings.get()
174
+ const controller = new AbortController()
175
+ runs.set(requestId, controller)
176
+ const started = Date.now()
177
+ let outputChars = 0
178
+ let route = null
179
+ try {
180
+ const skill = findSkill(current, skillId)
181
+ if (!skill || (skill.kind !== 'prompt' && skill.kind !== 'ask')) throw new ModelError('MODEL_FAILED', `unknown skill: ${skillId}`)
182
+ const input = {
183
+ text: String(text ?? '').slice(0, current.maxTextLength),
184
+ language: typeof language === 'string' ? language : undefined,
185
+ question: typeof question === 'string' ? question.slice(0, 2000) : undefined,
186
+ history: sanitizeHistory(history),
187
+ }
188
+ const prompt = (resolved) => {
189
+ route = resolved
190
+ return buildRequest(skill, current, { ...input, model: resolved.modelName })
191
+ }
192
+ const onUsage = (counts) => recordUsage(skillId, route, counts)
193
+ for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) {
194
+ outputChars += chunk.length
195
+ worker?.send({ type: 'result', requestId, chunk })
196
+ }
197
+ worker?.send({ type: 'result', requestId, done: true })
198
+ log('info', 'skill finished', { skill: skillId, ...routeFields(route), ms: Date.now() - started, inputChars: String(text ?? '').length, outputChars })
199
+ } catch (error) {
200
+ if (controller.signal.aborted && !(error instanceof ModelError)) {
201
+ log('info', 'skill cancelled', { skill: skillId, ms: Date.now() - started })
202
+ return
203
+ }
204
+ const failure = toFailure(error)
205
+ worker?.send({ type: 'result', requestId, error: failure, done: true })
206
+ log('warn', 'skill failed', { skill: skillId, ...routeFields(route), ...failure, ms: Date.now() - started })
207
+ } finally {
208
+ if (runs.get(requestId) === controller) runs.delete(requestId)
209
+ }
210
+ }
211
+
212
+ /** Runs one request to the end for the settings page's test buttons; the stream's own silence limits apply. */
213
+ async function collect(skill, build, current) {
214
+ const controller = new AbortController()
215
+ const started = Date.now()
216
+ let route = null
217
+ const prompt = (resolved) => {
218
+ route = resolved
219
+ return build(resolved)
220
+ }
221
+ try {
222
+ let output = ''
223
+ const onUsage = (counts) => recordUsage(skill, route, counts)
224
+ for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) output += chunk
225
+ return { ok: true, output, ms: Date.now() - started, route }
226
+ } catch (error) {
227
+ return { ok: false, error: toFailure(error), ms: Date.now() - started }
228
+ }
229
+ }
230
+
231
+ function openSettingsPage() {
232
+ emit('navigate', { panel: 'dsh-selection' })
233
+ // The page is open inside DSH; bring the window forward so the user sees it.
234
+ openExternal('dsh://open')
235
+ }
236
+
237
+ return {
238
+ getState() {
239
+ return {
240
+ settings: settings.get(),
241
+ defaults: defaultSettings(),
242
+ status,
243
+ recentApps: [...recentApps],
244
+ catalog: {
245
+ builtins: BUILTIN_SKILLS,
246
+ languages: LANGUAGES,
247
+ searchEngines: SEARCH_ENGINES,
248
+ icons: ICONS,
249
+ customIcons: CUSTOM_SKILL_ICONS,
250
+ limits: LIMITS,
251
+ },
252
+ }
253
+ },
254
+ /** Applies the changed fields only, so edits made meanwhile from the toolbar are not overwritten. */
255
+ saveSettings(patch) {
256
+ if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) throw new BadRequest('settings must be an object')
257
+ return settings.update((current) => ({ ...current, ...patch }))
258
+ },
259
+ resetSettings: () => settings.reset(),
260
+ getUsage: () => usage.summary(),
261
+ resetUsage() {
262
+ usage.reset()
263
+ const summary = usage.summary()
264
+ emit('usage', summary)
265
+ return summary
266
+ },
267
+ /** DSH's model providers changed (sign-in, sign-out, a new API key). */
268
+ invalidateModels: () => model.invalidate(),
269
+ async listModels() {
270
+ try {
271
+ const routes = await model.listRoutes({ fresh: true })
272
+ const selected = routes.length ? await model.resolveRoute(settings.get()) : null
273
+ return { routes, selected }
274
+ } catch (error) {
275
+ return { routes: [], selected: null, error: toFailure(error) }
276
+ }
277
+ },
278
+ /** Sends one short translation through the selected route; the settings page shows the outcome. */
279
+ testModel() {
280
+ const current = settings.get()
281
+ return collect('model-test', () => buildRequest(findSkill(current, 'translate'), { ...current, translateTarget: 'en' }, { text: '你好,世界' }), current)
282
+ },
283
+ /** Runs a draft custom skill against sample text without saving it. */
284
+ testSkill(input) {
285
+ const prompt = typeof input?.prompt === 'string' ? input.prompt.trim().slice(0, LIMITS.customPrompt) : ''
286
+ const text = typeof input?.text === 'string' ? input.text.trim().slice(0, 2000) : ''
287
+ if (!prompt || !text) throw new BadRequest('prompt and text are required')
288
+ const current = settings.get()
289
+ const skill = { id: 'custom-test', kind: 'prompt', custom: true, prompt }
290
+ return collect('skill-test', (route) => buildRequest(skill, current, { text, model: route.modelName }), current)
291
+ },
292
+ restartWorker() {
293
+ if (!worker) throw new BadRequest('the selection toolbar is not supported on this Host')
294
+ worker.restart()
295
+ },
296
+ openPermissionSettings() {
297
+ if (platform !== 'darwin') throw new BadRequest('only macOS needs an accessibility grant')
298
+ openExternal(ACCESSIBILITY_SETTINGS_URL)
299
+ },
300
+ subscribe(listener) {
301
+ subscribers.add(listener)
302
+ return () => subscribers.delete(listener)
303
+ },
304
+ async dispose() {
305
+ unsubscribe()
306
+ abortRuns()
307
+ usage.flush()
308
+ emit('closing', {})
309
+ subscribers.clear()
310
+ await worker?.stop()
311
+ },
312
+ }
313
+ }
314
+
315
+ function sanitizeHistory(history) {
316
+ if (!Array.isArray(history)) return []
317
+ return history.slice(-6)
318
+ .filter((turn) => typeof turn?.answer === 'string' && (typeof turn.question === 'string' || typeof turn.skillId === 'string'))
319
+ .map((turn) => ({
320
+ ...(typeof turn.question === 'string' ? { question: turn.question.slice(0, 2000) } : { skillId: turn.skillId.slice(0, 64) }),
321
+ ...(typeof turn.language === 'string' ? { language: turn.language.slice(0, 16) } : {}),
322
+ answer: turn.answer.slice(0, 6000),
323
+ }))
324
+ }
325
+
326
+ /** Which route answered, for the log: ids only. */
327
+ function routeFields(route) {
328
+ return route ? { provider: route.provider, model: route.model } : {}
329
+ }
330
+
331
+ function toFailure(error) {
332
+ if (error instanceof ModelError) return { code: error.code, detail: error.detail.slice(0, 300) }
333
+ return { code: 'MODEL_FAILED', detail: String(error?.message ?? error).slice(0, 300) }
334
+ }
@@ -0,0 +1,282 @@
1
+ // User settings: the defaults, a normalizer that turns any input into a valid value,
2
+ // and a file-backed store. Everything the settings page sends goes through normalizeSettings.
3
+ import { readFileSync, renameSync } from 'node:fs'
4
+ import { writeFileAtomic } from './files.mjs'
5
+ import { CUSTOM_SKILL_ICONS, ICONS } from '../shared/icons.mjs'
6
+
7
+ /** Built-in skills in their default order. `kind` decides who handles a click. */
8
+ export const BUILTIN_SKILLS = Object.freeze([
9
+ { id: 'translate', kind: 'prompt', icon: 'translate', name: { zh: '翻译', en: 'Translate' } },
10
+ { id: 'summarize', kind: 'prompt', icon: 'summarize', name: { zh: '总结', en: 'Summarize' } },
11
+ { id: 'explain', kind: 'prompt', icon: 'explain', name: { zh: '解释', en: 'Explain' } },
12
+ { id: 'ask', kind: 'ask', icon: 'ask', name: { zh: '问问 DSH', en: 'Ask DSH' } },
13
+ { id: 'speak', kind: 'speak', icon: 'speak', name: { zh: '朗读', en: 'Read aloud' } },
14
+ { id: 'copy', kind: 'copy', icon: 'copy', name: { zh: '复制', en: 'Copy' } },
15
+ { id: 'polish', kind: 'prompt', icon: 'polish', name: { zh: '润色', en: 'Polish' } },
16
+ { id: 'search', kind: 'search', icon: 'search', name: { zh: '搜索', en: 'Search' } },
17
+ ])
18
+
19
+ export const LANGUAGES = Object.freeze([
20
+ { code: 'zh-CN', name: '简体中文', english: 'Simplified Chinese' },
21
+ { code: 'zh-TW', name: '繁體中文', english: 'Traditional Chinese' },
22
+ { code: 'en', name: 'English', english: 'English' },
23
+ { code: 'ja', name: '日本語', english: 'Japanese' },
24
+ { code: 'ko', name: '한국어', english: 'Korean' },
25
+ { code: 'fr', name: 'Français', english: 'French' },
26
+ { code: 'de', name: 'Deutsch', english: 'German' },
27
+ { code: 'es', name: 'Español', english: 'Spanish' },
28
+ { code: 'ru', name: 'Русский', english: 'Russian' },
29
+ { code: 'pt', name: 'Português', english: 'Portuguese' },
30
+ ])
31
+
32
+ export const SEARCH_ENGINES = Object.freeze({
33
+ google: 'https://www.google.com/search?q=%s',
34
+ bing: 'https://www.bing.com/search?q=%s',
35
+ baidu: 'https://www.baidu.com/s?wd=%s',
36
+ duckduckgo: 'https://duckduckgo.com/?q=%s',
37
+ })
38
+
39
+ export const LIMITS = Object.freeze({
40
+ toolbarSize: { min: 1, max: 8 },
41
+ maxTextLength: { min: 200, max: 20000 },
42
+ customSkills: 30,
43
+ customName: 16,
44
+ customPrompt: 4000,
45
+ disabledApps: 200,
46
+ appName: 200,
47
+ })
48
+
49
+ const LANGUAGE_CODES = new Set(LANGUAGES.map((language) => language.code))
50
+ const BUILTIN_IDS = new Set(BUILTIN_SKILLS.map((skill) => skill.id))
51
+ const CUSTOM_ID = /^custom-[a-z0-9]{4,24}$/
52
+
53
+ /** @returns {Settings} */
54
+ export function defaultSettings() {
55
+ return {
56
+ version: 1,
57
+ enabled: true,
58
+ trigger: 'auto',
59
+ toolbarSize: 5,
60
+ toolbarStyle: 'full',
61
+ skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
62
+ disabledApps: [],
63
+ nativeLanguage: 'zh-CN',
64
+ translateTarget: 'auto',
65
+ answerLanguage: 'native',
66
+ model: { provider: '', model: '' },
67
+ maxTextLength: 4000,
68
+ clipboardFallback: true,
69
+ searchEngine: 'google',
70
+ customSearchUrl: '',
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Coerces any value into valid settings. Unknown fields are dropped, bad values fall back to defaults.
76
+ * @param {unknown} input
77
+ * @returns {Settings}
78
+ */
79
+ export function normalizeSettings(input) {
80
+ const defaults = defaultSettings()
81
+ const value = isObject(input) ? input : {}
82
+ const settings = {
83
+ version: 1,
84
+ enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
85
+ trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
86
+ toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
87
+ toolbarStyle: value.toolbarStyle === 'mini' ? 'mini' : 'full',
88
+ skills: normalizeSkills(value.skills),
89
+ disabledApps: normalizeApps(value.disabledApps),
90
+ nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
91
+ translateTarget: value.translateTarget === 'auto' || LANGUAGE_CODES.has(value.translateTarget) ? value.translateTarget : 'auto',
92
+ answerLanguage: ['native', 'source'].includes(value.answerLanguage) || LANGUAGE_CODES.has(value.answerLanguage) ? value.answerLanguage : 'native',
93
+ model: {
94
+ provider: shortString(value.model?.provider, 100),
95
+ model: shortString(value.model?.model, 200),
96
+ },
97
+ maxTextLength: clampInt(value.maxTextLength, LIMITS.maxTextLength, defaults.maxTextLength),
98
+ clipboardFallback: typeof value.clipboardFallback === 'boolean' ? value.clipboardFallback : defaults.clipboardFallback,
99
+ searchEngine: value.searchEngine === 'custom' || Object.hasOwn(SEARCH_ENGINES, value.searchEngine) ? value.searchEngine : 'google',
100
+ customSearchUrl: isSearchTemplate(value.customSearchUrl) ? value.customSearchUrl.trim() : '',
101
+ }
102
+ if (settings.searchEngine === 'custom' && !settings.customSearchUrl) settings.searchEngine = 'google'
103
+ return settings
104
+ }
105
+
106
+ /** URL template for the search skill; `%s` is replaced by the encoded selection. */
107
+ export function searchTemplate(settings) {
108
+ return settings.searchEngine === 'custom' ? settings.customSearchUrl : SEARCH_ENGINES[settings.searchEngine]
109
+ }
110
+
111
+ /** Enabled skills in toolbar order, with what the toolbar needs to draw them. */
112
+ export function enabledSkills(settings) {
113
+ const builtins = new Map(BUILTIN_SKILLS.map((skill) => [skill.id, skill]))
114
+ return settings.skills.filter((entry) => entry.enabled).map((entry) => {
115
+ if (entry.custom) return { id: entry.id, kind: 'prompt', icon: entry.icon, name: entry.name, custom: true }
116
+ const builtin = builtins.get(entry.id)
117
+ return { id: builtin.id, kind: builtin.kind, icon: builtin.icon, name: builtin.name, custom: false }
118
+ })
119
+ }
120
+
121
+ /** Looks a skill up by id, including disabled ones (a click may race a settings change). */
122
+ export function findSkill(settings, id) {
123
+ const entry = settings.skills.find((skill) => skill.id === id)
124
+ if (!entry) return null
125
+ if (entry.custom) return { ...entry, kind: 'prompt' }
126
+ return { ...BUILTIN_SKILLS.find((skill) => skill.id === id), enabled: entry.enabled }
127
+ }
128
+
129
+ const UNREAD_RETRY_MS = 2000
130
+
131
+ /**
132
+ * File-backed settings. Writes are atomic (temp file + rename). A file that is not valid JSON is moved
133
+ * aside and replaced by defaults instead of blocking startup.
134
+ * @param {{ file: string, log?: (level: string, message: string, fields?: object) => void }} options
135
+ */
136
+ export function createSettingsStore({ file, log }) {
137
+ const listeners = new Set()
138
+ // The file exists but could not be read (locked, no permission, out of descriptors). Until a read
139
+ // succeeds the defaults stand in, nothing is written over the file, and it is read again now and
140
+ // then; when that works, every listener (the worker among them) gets the real settings.
141
+ let unread = false
142
+ let retry = null
143
+ let current = load()
144
+
145
+ function load() {
146
+ let text
147
+ try {
148
+ text = readFileSync(file, 'utf8')
149
+ } catch (error) {
150
+ if (error?.code === 'ENOENT') return defaultSettings()
151
+ if (!unread) log?.('warn', 'settings file could not be read, using defaults for now', { error: String(error?.message ?? error) })
152
+ unread = true
153
+ retry ??= setTimeout(() => {
154
+ retry = null
155
+ latest()
156
+ }, UNREAD_RETRY_MS)
157
+ retry.unref?.()
158
+ return defaultSettings()
159
+ }
160
+ unread = false
161
+ try {
162
+ return normalizeSettings(JSON.parse(text))
163
+ } catch (error) {
164
+ log?.('warn', 'settings file is not valid JSON, moved aside', { error: String(error?.message ?? error) })
165
+ try { renameSync(file, `${file}.broken-${Date.now()}`) } catch { /* nothing left to preserve */ }
166
+ return defaultSettings()
167
+ }
168
+ }
169
+
170
+ function latest() {
171
+ if (!unread) return current
172
+ const next = load()
173
+ if (unread) return current
174
+ current = next
175
+ for (const listener of listeners) listener(current)
176
+ return current
177
+ }
178
+
179
+ function save(next) {
180
+ latest()
181
+ if (unread) throw new Error('the settings file cannot be read right now, so it was left unchanged')
182
+ const normalized = normalizeSettings(next)
183
+ // A change that normalization undid, or no change at all: nothing to write or announce.
184
+ if (JSON.stringify(normalized) === JSON.stringify(current)) return current
185
+ writeFileAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`)
186
+ current = normalized
187
+ for (const listener of listeners) listener(current)
188
+ return current
189
+ }
190
+
191
+ return {
192
+ get: latest,
193
+ save,
194
+ update: (change) => save(change(structuredClone(latest()))),
195
+ reset: () => save(defaultSettings()),
196
+ onChange(listener) {
197
+ listeners.add(listener)
198
+ return () => listeners.delete(listener)
199
+ },
200
+ }
201
+ }
202
+
203
+ function normalizeSkills(input) {
204
+ const skills = []
205
+ const seen = new Set()
206
+ let customCount = 0
207
+ for (const entry of Array.isArray(input) ? input : []) {
208
+ if (!isObject(entry) || typeof entry.id !== 'string' || seen.has(entry.id)) continue
209
+ const enabled = typeof entry.enabled === 'boolean' ? entry.enabled : true
210
+ if (BUILTIN_IDS.has(entry.id)) {
211
+ skills.push({ id: entry.id, enabled })
212
+ seen.add(entry.id)
213
+ continue
214
+ }
215
+ if (!CUSTOM_ID.test(entry.id) || customCount >= LIMITS.customSkills) continue
216
+ const name = shortString(entry.name, LIMITS.customName)
217
+ const prompt = shortString(entry.prompt, LIMITS.customPrompt)
218
+ if (!name || !prompt) continue
219
+ const icon = CUSTOM_SKILL_ICONS.includes(entry.icon) && Object.hasOwn(ICONS, entry.icon) ? entry.icon : 'sparkles'
220
+ skills.push({ id: entry.id, custom: true, enabled, name, icon, prompt })
221
+ seen.add(entry.id)
222
+ customCount += 1
223
+ }
224
+ // Skills added in a newer release show up enabled at the end of an existing list.
225
+ for (const builtin of BUILTIN_SKILLS) {
226
+ if (!seen.has(builtin.id)) skills.push({ id: builtin.id, enabled: true })
227
+ }
228
+ return skills
229
+ }
230
+
231
+ function normalizeApps(input) {
232
+ const apps = new Set()
233
+ for (const entry of Array.isArray(input) ? input : []) {
234
+ const app = shortString(entry, LIMITS.appName)
235
+ if (app) apps.add(app)
236
+ if (apps.size >= LIMITS.disabledApps) break
237
+ }
238
+ return [...apps]
239
+ }
240
+
241
+ function isSearchTemplate(value) {
242
+ if (typeof value !== 'string' || value.length > 500 || !value.includes('%s')) return false
243
+ try {
244
+ const url = new URL(value.trim().replace('%s', 'x'))
245
+ return url.protocol === 'https:' || url.protocol === 'http:'
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+
251
+ function clampInt(value, { min, max }, fallback) {
252
+ const number = Number(value)
253
+ if (!Number.isFinite(number)) return fallback
254
+ return Math.min(max, Math.max(min, Math.round(number)))
255
+ }
256
+
257
+ function shortString(value, max) {
258
+ return typeof value === 'string' ? value.trim().slice(0, max) : ''
259
+ }
260
+
261
+ function isObject(value) {
262
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
263
+ }
264
+
265
+ /**
266
+ * @typedef {object} Settings
267
+ * @property {1} version
268
+ * @property {boolean} enabled Show the toolbar when text is selected.
269
+ * @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
270
+ * @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
271
+ * @property {'full' | 'mini'} toolbarStyle Full: skill buttons on the toolbar. Mini: one icon whose menu holds every skill.
272
+ * @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
273
+ * @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
274
+ * @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
275
+ * @property {string} translateTarget `auto` or a language code.
276
+ * @property {string} answerLanguage `native`, `source` or a language code, for summaries, explanations and answers.
277
+ * @property {{ provider: string, model: string }} model Empty strings pick a route automatically.
278
+ * @property {number} maxTextLength Longer selections are cut before being sent anywhere.
279
+ * @property {boolean} clipboardFallback Simulate a copy when accessibility APIs cannot read the selection.
280
+ * @property {string} searchEngine A key of SEARCH_ENGINES or `custom`.
281
+ * @property {string} customSearchUrl http(s) URL containing `%s`.
282
+ */