@followbin/dsh-selection 0.0.0-stage → 0.1.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 +140 -2
  3. package/README.zh-CN.md +141 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1055 -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 +324 -0
  17. package/src/host/settings.mjs +279 -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 +43 -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 +261 -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 +263 -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 +152 -0
  35. package/ui/toolbar.html +52 -0
  36. package/ui/toolbar.js +492 -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,324 @@
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
+ skills: enabledSkills(current),
105
+ disabledApps: current.disabledApps,
106
+ clipboardFallback: current.clipboardFallback,
107
+ maxTextLength: current.maxTextLength,
108
+ searchTemplate: searchTemplate(current),
109
+ translateTarget: current.translateTarget,
110
+ languages: LANGUAGES.map(({ code, name }) => ({ code, name })),
111
+ }
112
+ }
113
+
114
+ function handleWorkerMessage(message) {
115
+ switch (message?.type) {
116
+ case 'hello':
117
+ worker?.send({ type: 'config', config: workerConfig(settings.get()) })
118
+ break
119
+ case 'ready':
120
+ setStatus({ hook: Boolean(message.hookStarted) })
121
+ break
122
+ case 'permission':
123
+ setStatus({ permission: message.trusted ? 'granted' : 'denied' })
124
+ break
125
+ case 'selection':
126
+ rememberApp(message.app)
127
+ break
128
+ case 'run':
129
+ void runSkill(message)
130
+ break
131
+ case 'cancel':
132
+ runs.get(message.requestId)?.abort()
133
+ break
134
+ case 'disable-app':
135
+ if (typeof message.app === 'string' && message.app) {
136
+ try {
137
+ const saved = settings.update((current) => ({ ...current, disabledApps: [...current.disabledApps, message.app] }))
138
+ if (saved.disabledApps.includes(message.app)) log('info', 'app disabled from toolbar', { app: message.app })
139
+ else log('warn', 'app not disabled: the list is full', { app: message.app, limit: LIMITS.disabledApps })
140
+ } catch (error) {
141
+ log('error', 'saving settings failed', { error: String(error?.message ?? error) })
142
+ }
143
+ }
144
+ break
145
+ case 'open-settings':
146
+ openSettingsPage()
147
+ break
148
+ case 'log':
149
+ log(message.level ?? 'info', `worker: ${message.message}`, message.fields ?? {})
150
+ break
151
+ }
152
+ }
153
+
154
+ function rememberApp(app) {
155
+ if (typeof app !== 'string' || !app) return
156
+ const index = recentApps.indexOf(app)
157
+ if (index !== -1) recentApps.splice(index, 1)
158
+ recentApps.unshift(app)
159
+ recentApps.length = Math.min(recentApps.length, RECENT_APPS)
160
+ }
161
+
162
+ async function runSkill({ requestId, skillId, text, language, question, history }) {
163
+ const current = settings.get()
164
+ const controller = new AbortController()
165
+ runs.set(requestId, controller)
166
+ const started = Date.now()
167
+ let outputChars = 0
168
+ let route = null
169
+ try {
170
+ const skill = findSkill(current, skillId)
171
+ if (!skill || (skill.kind !== 'prompt' && skill.kind !== 'ask')) throw new ModelError('MODEL_FAILED', `unknown skill: ${skillId}`)
172
+ const input = {
173
+ text: String(text ?? '').slice(0, current.maxTextLength),
174
+ language: typeof language === 'string' ? language : undefined,
175
+ question: typeof question === 'string' ? question.slice(0, 2000) : undefined,
176
+ history: sanitizeHistory(history),
177
+ }
178
+ const prompt = (resolved) => {
179
+ route = resolved
180
+ return buildRequest(skill, current, { ...input, model: resolved.modelName })
181
+ }
182
+ const onUsage = (counts) => recordUsage(skillId, route, counts)
183
+ for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) {
184
+ outputChars += chunk.length
185
+ worker?.send({ type: 'result', requestId, chunk })
186
+ }
187
+ worker?.send({ type: 'result', requestId, done: true })
188
+ log('info', 'skill finished', { skill: skillId, ...routeFields(route), ms: Date.now() - started, inputChars: String(text ?? '').length, outputChars })
189
+ } catch (error) {
190
+ if (controller.signal.aborted && !(error instanceof ModelError)) {
191
+ log('info', 'skill cancelled', { skill: skillId, ms: Date.now() - started })
192
+ return
193
+ }
194
+ const failure = toFailure(error)
195
+ worker?.send({ type: 'result', requestId, error: failure, done: true })
196
+ log('warn', 'skill failed', { skill: skillId, ...routeFields(route), ...failure, ms: Date.now() - started })
197
+ } finally {
198
+ if (runs.get(requestId) === controller) runs.delete(requestId)
199
+ }
200
+ }
201
+
202
+ /** Runs one request to the end for the settings page's test buttons; the stream's own silence limits apply. */
203
+ async function collect(skill, build, current) {
204
+ const controller = new AbortController()
205
+ const started = Date.now()
206
+ let route = null
207
+ const prompt = (resolved) => {
208
+ route = resolved
209
+ return build(resolved)
210
+ }
211
+ try {
212
+ let output = ''
213
+ const onUsage = (counts) => recordUsage(skill, route, counts)
214
+ for await (const chunk of model.stream({ settings: current, prompt, signal: controller.signal, onUsage })) output += chunk
215
+ return { ok: true, output, ms: Date.now() - started, route }
216
+ } catch (error) {
217
+ return { ok: false, error: toFailure(error), ms: Date.now() - started }
218
+ }
219
+ }
220
+
221
+ function openSettingsPage() {
222
+ emit('navigate', { panel: 'dsh-selection' })
223
+ // The page is open inside DSH; bring the window forward so the user sees it.
224
+ openExternal('dsh://open')
225
+ }
226
+
227
+ return {
228
+ getState() {
229
+ return {
230
+ settings: settings.get(),
231
+ defaults: defaultSettings(),
232
+ status,
233
+ recentApps: [...recentApps],
234
+ catalog: {
235
+ builtins: BUILTIN_SKILLS,
236
+ languages: LANGUAGES,
237
+ searchEngines: SEARCH_ENGINES,
238
+ icons: ICONS,
239
+ customIcons: CUSTOM_SKILL_ICONS,
240
+ limits: LIMITS,
241
+ },
242
+ }
243
+ },
244
+ /** Applies the changed fields only, so edits made meanwhile from the toolbar are not overwritten. */
245
+ saveSettings(patch) {
246
+ if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) throw new BadRequest('settings must be an object')
247
+ return settings.update((current) => ({ ...current, ...patch }))
248
+ },
249
+ resetSettings: () => settings.reset(),
250
+ getUsage: () => usage.summary(),
251
+ resetUsage() {
252
+ usage.reset()
253
+ const summary = usage.summary()
254
+ emit('usage', summary)
255
+ return summary
256
+ },
257
+ /** DSH's model providers changed (sign-in, sign-out, a new API key). */
258
+ invalidateModels: () => model.invalidate(),
259
+ async listModels() {
260
+ try {
261
+ const routes = await model.listRoutes({ fresh: true })
262
+ const selected = routes.length ? await model.resolveRoute(settings.get()) : null
263
+ return { routes, selected }
264
+ } catch (error) {
265
+ return { routes: [], selected: null, error: toFailure(error) }
266
+ }
267
+ },
268
+ /** Sends one short translation through the selected route; the settings page shows the outcome. */
269
+ testModel() {
270
+ const current = settings.get()
271
+ return collect('model-test', () => buildRequest(findSkill(current, 'translate'), { ...current, translateTarget: 'en' }, { text: '你好,世界' }), current)
272
+ },
273
+ /** Runs a draft custom skill against sample text without saving it. */
274
+ testSkill(input) {
275
+ const prompt = typeof input?.prompt === 'string' ? input.prompt.trim().slice(0, LIMITS.customPrompt) : ''
276
+ const text = typeof input?.text === 'string' ? input.text.trim().slice(0, 2000) : ''
277
+ if (!prompt || !text) throw new BadRequest('prompt and text are required')
278
+ const current = settings.get()
279
+ const skill = { id: 'custom-test', kind: 'prompt', custom: true, prompt }
280
+ return collect('skill-test', (route) => buildRequest(skill, current, { text, model: route.modelName }), current)
281
+ },
282
+ restartWorker() {
283
+ if (!worker) throw new BadRequest('the selection toolbar is not supported on this Host')
284
+ worker.restart()
285
+ },
286
+ openPermissionSettings() {
287
+ if (platform !== 'darwin') throw new BadRequest('only macOS needs an accessibility grant')
288
+ openExternal(ACCESSIBILITY_SETTINGS_URL)
289
+ },
290
+ subscribe(listener) {
291
+ subscribers.add(listener)
292
+ return () => subscribers.delete(listener)
293
+ },
294
+ async dispose() {
295
+ unsubscribe()
296
+ abortRuns()
297
+ usage.flush()
298
+ emit('closing', {})
299
+ subscribers.clear()
300
+ await worker?.stop()
301
+ },
302
+ }
303
+ }
304
+
305
+ function sanitizeHistory(history) {
306
+ if (!Array.isArray(history)) return []
307
+ return history.slice(-6)
308
+ .filter((turn) => typeof turn?.answer === 'string' && (typeof turn.question === 'string' || typeof turn.skillId === 'string'))
309
+ .map((turn) => ({
310
+ ...(typeof turn.question === 'string' ? { question: turn.question.slice(0, 2000) } : { skillId: turn.skillId.slice(0, 64) }),
311
+ ...(typeof turn.language === 'string' ? { language: turn.language.slice(0, 16) } : {}),
312
+ answer: turn.answer.slice(0, 6000),
313
+ }))
314
+ }
315
+
316
+ /** Which route answered, for the log: ids only. */
317
+ function routeFields(route) {
318
+ return route ? { provider: route.provider, model: route.model } : {}
319
+ }
320
+
321
+ function toFailure(error) {
322
+ if (error instanceof ModelError) return { code: error.code, detail: error.detail.slice(0, 300) }
323
+ return { code: 'MODEL_FAILED', detail: String(error?.message ?? error).slice(0, 300) }
324
+ }
@@ -0,0 +1,279 @@
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
+ skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
61
+ disabledApps: [],
62
+ nativeLanguage: 'zh-CN',
63
+ translateTarget: 'auto',
64
+ answerLanguage: 'native',
65
+ model: { provider: '', model: '' },
66
+ maxTextLength: 4000,
67
+ clipboardFallback: true,
68
+ searchEngine: 'google',
69
+ customSearchUrl: '',
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Coerces any value into valid settings. Unknown fields are dropped, bad values fall back to defaults.
75
+ * @param {unknown} input
76
+ * @returns {Settings}
77
+ */
78
+ export function normalizeSettings(input) {
79
+ const defaults = defaultSettings()
80
+ const value = isObject(input) ? input : {}
81
+ const settings = {
82
+ version: 1,
83
+ enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
84
+ trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
85
+ toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
86
+ skills: normalizeSkills(value.skills),
87
+ disabledApps: normalizeApps(value.disabledApps),
88
+ nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
89
+ translateTarget: value.translateTarget === 'auto' || LANGUAGE_CODES.has(value.translateTarget) ? value.translateTarget : 'auto',
90
+ answerLanguage: ['native', 'source'].includes(value.answerLanguage) || LANGUAGE_CODES.has(value.answerLanguage) ? value.answerLanguage : 'native',
91
+ model: {
92
+ provider: shortString(value.model?.provider, 100),
93
+ model: shortString(value.model?.model, 200),
94
+ },
95
+ maxTextLength: clampInt(value.maxTextLength, LIMITS.maxTextLength, defaults.maxTextLength),
96
+ clipboardFallback: typeof value.clipboardFallback === 'boolean' ? value.clipboardFallback : defaults.clipboardFallback,
97
+ searchEngine: value.searchEngine === 'custom' || Object.hasOwn(SEARCH_ENGINES, value.searchEngine) ? value.searchEngine : 'google',
98
+ customSearchUrl: isSearchTemplate(value.customSearchUrl) ? value.customSearchUrl.trim() : '',
99
+ }
100
+ if (settings.searchEngine === 'custom' && !settings.customSearchUrl) settings.searchEngine = 'google'
101
+ return settings
102
+ }
103
+
104
+ /** URL template for the search skill; `%s` is replaced by the encoded selection. */
105
+ export function searchTemplate(settings) {
106
+ return settings.searchEngine === 'custom' ? settings.customSearchUrl : SEARCH_ENGINES[settings.searchEngine]
107
+ }
108
+
109
+ /** Enabled skills in toolbar order, with what the toolbar needs to draw them. */
110
+ export function enabledSkills(settings) {
111
+ const builtins = new Map(BUILTIN_SKILLS.map((skill) => [skill.id, skill]))
112
+ return settings.skills.filter((entry) => entry.enabled).map((entry) => {
113
+ if (entry.custom) return { id: entry.id, kind: 'prompt', icon: entry.icon, name: entry.name, custom: true }
114
+ const builtin = builtins.get(entry.id)
115
+ return { id: builtin.id, kind: builtin.kind, icon: builtin.icon, name: builtin.name, custom: false }
116
+ })
117
+ }
118
+
119
+ /** Looks a skill up by id, including disabled ones (a click may race a settings change). */
120
+ export function findSkill(settings, id) {
121
+ const entry = settings.skills.find((skill) => skill.id === id)
122
+ if (!entry) return null
123
+ if (entry.custom) return { ...entry, kind: 'prompt' }
124
+ return { ...BUILTIN_SKILLS.find((skill) => skill.id === id), enabled: entry.enabled }
125
+ }
126
+
127
+ const UNREAD_RETRY_MS = 2000
128
+
129
+ /**
130
+ * File-backed settings. Writes are atomic (temp file + rename). A file that is not valid JSON is moved
131
+ * aside and replaced by defaults instead of blocking startup.
132
+ * @param {{ file: string, log?: (level: string, message: string, fields?: object) => void }} options
133
+ */
134
+ export function createSettingsStore({ file, log }) {
135
+ const listeners = new Set()
136
+ // The file exists but could not be read (locked, no permission, out of descriptors). Until a read
137
+ // succeeds the defaults stand in, nothing is written over the file, and it is read again now and
138
+ // then; when that works, every listener (the worker among them) gets the real settings.
139
+ let unread = false
140
+ let retry = null
141
+ let current = load()
142
+
143
+ function load() {
144
+ let text
145
+ try {
146
+ text = readFileSync(file, 'utf8')
147
+ } catch (error) {
148
+ if (error?.code === 'ENOENT') return defaultSettings()
149
+ if (!unread) log?.('warn', 'settings file could not be read, using defaults for now', { error: String(error?.message ?? error) })
150
+ unread = true
151
+ retry ??= setTimeout(() => {
152
+ retry = null
153
+ latest()
154
+ }, UNREAD_RETRY_MS)
155
+ retry.unref?.()
156
+ return defaultSettings()
157
+ }
158
+ unread = false
159
+ try {
160
+ return normalizeSettings(JSON.parse(text))
161
+ } catch (error) {
162
+ log?.('warn', 'settings file is not valid JSON, moved aside', { error: String(error?.message ?? error) })
163
+ try { renameSync(file, `${file}.broken-${Date.now()}`) } catch { /* nothing left to preserve */ }
164
+ return defaultSettings()
165
+ }
166
+ }
167
+
168
+ function latest() {
169
+ if (!unread) return current
170
+ const next = load()
171
+ if (unread) return current
172
+ current = next
173
+ for (const listener of listeners) listener(current)
174
+ return current
175
+ }
176
+
177
+ function save(next) {
178
+ latest()
179
+ if (unread) throw new Error('the settings file cannot be read right now, so it was left unchanged')
180
+ const normalized = normalizeSettings(next)
181
+ // A change that normalization undid, or no change at all: nothing to write or announce.
182
+ if (JSON.stringify(normalized) === JSON.stringify(current)) return current
183
+ writeFileAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`)
184
+ current = normalized
185
+ for (const listener of listeners) listener(current)
186
+ return current
187
+ }
188
+
189
+ return {
190
+ get: latest,
191
+ save,
192
+ update: (change) => save(change(structuredClone(latest()))),
193
+ reset: () => save(defaultSettings()),
194
+ onChange(listener) {
195
+ listeners.add(listener)
196
+ return () => listeners.delete(listener)
197
+ },
198
+ }
199
+ }
200
+
201
+ function normalizeSkills(input) {
202
+ const skills = []
203
+ const seen = new Set()
204
+ let customCount = 0
205
+ for (const entry of Array.isArray(input) ? input : []) {
206
+ if (!isObject(entry) || typeof entry.id !== 'string' || seen.has(entry.id)) continue
207
+ const enabled = typeof entry.enabled === 'boolean' ? entry.enabled : true
208
+ if (BUILTIN_IDS.has(entry.id)) {
209
+ skills.push({ id: entry.id, enabled })
210
+ seen.add(entry.id)
211
+ continue
212
+ }
213
+ if (!CUSTOM_ID.test(entry.id) || customCount >= LIMITS.customSkills) continue
214
+ const name = shortString(entry.name, LIMITS.customName)
215
+ const prompt = shortString(entry.prompt, LIMITS.customPrompt)
216
+ if (!name || !prompt) continue
217
+ const icon = CUSTOM_SKILL_ICONS.includes(entry.icon) && Object.hasOwn(ICONS, entry.icon) ? entry.icon : 'sparkles'
218
+ skills.push({ id: entry.id, custom: true, enabled, name, icon, prompt })
219
+ seen.add(entry.id)
220
+ customCount += 1
221
+ }
222
+ // Skills added in a newer release show up enabled at the end of an existing list.
223
+ for (const builtin of BUILTIN_SKILLS) {
224
+ if (!seen.has(builtin.id)) skills.push({ id: builtin.id, enabled: true })
225
+ }
226
+ return skills
227
+ }
228
+
229
+ function normalizeApps(input) {
230
+ const apps = new Set()
231
+ for (const entry of Array.isArray(input) ? input : []) {
232
+ const app = shortString(entry, LIMITS.appName)
233
+ if (app) apps.add(app)
234
+ if (apps.size >= LIMITS.disabledApps) break
235
+ }
236
+ return [...apps]
237
+ }
238
+
239
+ function isSearchTemplate(value) {
240
+ if (typeof value !== 'string' || value.length > 500 || !value.includes('%s')) return false
241
+ try {
242
+ const url = new URL(value.trim().replace('%s', 'x'))
243
+ return url.protocol === 'https:' || url.protocol === 'http:'
244
+ } catch {
245
+ return false
246
+ }
247
+ }
248
+
249
+ function clampInt(value, { min, max }, fallback) {
250
+ const number = Number(value)
251
+ if (!Number.isFinite(number)) return fallback
252
+ return Math.min(max, Math.max(min, Math.round(number)))
253
+ }
254
+
255
+ function shortString(value, max) {
256
+ return typeof value === 'string' ? value.trim().slice(0, max) : ''
257
+ }
258
+
259
+ function isObject(value) {
260
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
261
+ }
262
+
263
+ /**
264
+ * @typedef {object} Settings
265
+ * @property {1} version
266
+ * @property {boolean} enabled Show the toolbar when text is selected.
267
+ * @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
268
+ * @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
269
+ * @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
270
+ * @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
271
+ * @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
272
+ * @property {string} translateTarget `auto` or a language code.
273
+ * @property {string} answerLanguage `native`, `source` or a language code, for summaries, explanations and answers.
274
+ * @property {{ provider: string, model: string }} model Empty strings pick a route automatically.
275
+ * @property {number} maxTextLength Longer selections are cut before being sent anywhere.
276
+ * @property {boolean} clipboardFallback Simulate a copy when accessibility APIs cannot read the selection.
277
+ * @property {string} searchEngine A key of SEARCH_ENGINES or `custom`.
278
+ * @property {string} customSearchUrl http(s) URL containing `%s`.
279
+ */