@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,491 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ // OpenSol keeps its data in ~/.opensol. An existing ~/.maxcode (from before the
8
+ // rename) keeps being used so saved keys, chats and plugins are not orphaned.
9
+ function resolveDataDir() {
10
+ const override = process.env.OPENSOL_DATA || process.env.MAXCODE_DATA
11
+ if (override) return path.resolve(override)
12
+ const next = path.join(os.homedir(), '.opensol')
13
+ const legacy = path.join(os.homedir(), '.maxcode')
14
+ return !existsSync(next) && existsSync(legacy) ? legacy : next
15
+ }
16
+
17
+ export const DATA_DIR = resolveDataDir()
18
+
19
+ const STATE_FILE = path.join(DATA_DIR, 'state.json')
20
+ const SECRETS_FILE = path.join(DATA_DIR, 'secrets.enc')
21
+ const MASTER_FILE = path.join(DATA_DIR, 'master.key')
22
+ const MODELS_CACHE = path.join(DATA_DIR, 'models-cache.json')
23
+ const AAD = Buffer.from('maxcode-secrets-v1', 'utf8')
24
+
25
+ export const STATE_VERSION = 2
26
+
27
+ const DEFAULT_STATE = () => ({
28
+ version: STATE_VERSION,
29
+ createdAt: new Date().toISOString(),
30
+ settings: {},
31
+ chats: { sessions: [], activeId: null },
32
+ workspaces: [],
33
+ activeWorkspace: null,
34
+ legacyMigratedAt: null,
35
+ })
36
+
37
+ const EMPTY_SECRETS = () => ({
38
+ providers: {},
39
+ search: {},
40
+ deploy: {
41
+ cloudflare: {},
42
+ netlify: {},
43
+ vercel: {},
44
+ },
45
+ })
46
+
47
+ const queues = new Map()
48
+ let stateCache = null
49
+ let secretsCache = null
50
+ let masterKeyCache = null
51
+
52
+ export async function ensureDataDir() {
53
+ await mkdir(DATA_DIR, { recursive: true, mode: 0o700 })
54
+ await chmod(DATA_DIR, 0o700).catch(() => undefined)
55
+ }
56
+
57
+ function enqueue(key, task) {
58
+ const previous = queues.get(key) ?? Promise.resolve()
59
+ const next = previous.then(task, task)
60
+ // The tail never rejects (callers handle `next`), so a failed write cannot
61
+ // surface as an unhandled rejection, and the entry is actually cleaned up.
62
+ const tail = next.then(
63
+ () => undefined,
64
+ () => undefined,
65
+ )
66
+ queues.set(key, tail)
67
+ void tail.then(() => {
68
+ if (queues.get(key) === tail) queues.delete(key)
69
+ })
70
+ return next
71
+ }
72
+
73
+ async function readJson(file, fallback) {
74
+ try {
75
+ return JSON.parse(await readFile(file, 'utf8'))
76
+ } catch (error) {
77
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return fallback
78
+ if (error instanceof SyntaxError) {
79
+ const backup = `${file}.broken-${Date.now()}`
80
+ await rename(file, backup).catch(() => undefined)
81
+ throw new Error(`OpenSol data is damaged. The file was moved to ${backup}.`)
82
+ }
83
+ throw error
84
+ }
85
+ }
86
+
87
+ async function writeAtomic(file, value, mode = 0o600) {
88
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
89
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode })
90
+ await chmod(temp, mode).catch(() => undefined)
91
+ await rename(temp, file)
92
+ }
93
+
94
+ function mergeLegacySettings(current, legacy) {
95
+ if (!legacy || typeof legacy !== 'object') return current
96
+ const merged = { ...legacy, ...current }
97
+ merged.providers = { ...(legacy.providers ?? {}), ...(current.providers ?? {}) }
98
+ merged.tools = { ...(legacy.tools ?? {}), ...(current.tools ?? {}) }
99
+ if (!Array.isArray(current.skills) || current.skills.length === 0) {
100
+ if (Array.isArray(legacy.skills)) merged.skills = legacy.skills
101
+ }
102
+ return merged
103
+ }
104
+
105
+ export async function loadState() {
106
+ if (stateCache) return stateCache
107
+ await ensureDataDir()
108
+ const parsed = await readJson(STATE_FILE, null)
109
+ const base = DEFAULT_STATE()
110
+ if (!parsed || typeof parsed !== 'object') {
111
+ stateCache = base
112
+ return stateCache
113
+ }
114
+ stateCache = {
115
+ ...base,
116
+ ...parsed,
117
+ version: STATE_VERSION,
118
+ settings:
119
+ parsed.settings && typeof parsed.settings === 'object' ? parsed.settings : {},
120
+ chats:
121
+ parsed.chats && Array.isArray(parsed.chats.sessions)
122
+ ? {
123
+ sessions: parsed.chats.sessions,
124
+ activeId: typeof parsed.chats.activeId === 'string' ? parsed.chats.activeId : null,
125
+ }
126
+ : base.chats,
127
+ workspaces: Array.isArray(parsed.workspaces)
128
+ ? parsed.workspaces
129
+ .filter((entry) => entry && typeof entry.path === 'string')
130
+ .slice(0, 20)
131
+ : [],
132
+ activeWorkspace: typeof parsed.activeWorkspace === 'string' ? parsed.activeWorkspace : null,
133
+ }
134
+ return stateCache
135
+ }
136
+
137
+ export async function saveState(next) {
138
+ stateCache = next
139
+ await enqueue('state', () => writeAtomic(STATE_FILE, next))
140
+ return next
141
+ }
142
+
143
+ export async function patchState(patch) {
144
+ const state = await loadState()
145
+ const next = { ...state, ...patch }
146
+ return saveState(next)
147
+ }
148
+
149
+ export function cachedState() {
150
+ return stateCache
151
+ }
152
+
153
+ async function masterKey() {
154
+ if (masterKeyCache) return masterKeyCache
155
+ await ensureDataDir()
156
+ try {
157
+ const raw = (await readFile(MASTER_FILE, 'utf8')).trim()
158
+ const key = Buffer.from(raw, 'base64')
159
+ if (key.length !== 32) throw new Error('The local master key is invalid.')
160
+ masterKeyCache = key
161
+ return key
162
+ } catch (error) {
163
+ if (!error || error.code !== 'ENOENT') throw error
164
+ const key = randomBytes(32)
165
+ await writeFile(MASTER_FILE, `${key.toString('base64')}\n`, { mode: 0o600 })
166
+ await chmod(MASTER_FILE, 0o600).catch(() => undefined)
167
+ masterKeyCache = key
168
+ return key
169
+ }
170
+ }
171
+
172
+ export async function loadSecrets() {
173
+ if (secretsCache) return secretsCache
174
+ await ensureDataDir()
175
+ const envelope = await readJson(SECRETS_FILE, null)
176
+ if (!envelope) {
177
+ secretsCache = EMPTY_SECRETS()
178
+ return secretsCache
179
+ }
180
+ try {
181
+ const key = await masterKey()
182
+ const iv = Buffer.from(envelope.iv, 'base64')
183
+ const tag = Buffer.from(envelope.tag, 'base64')
184
+ const data = Buffer.from(envelope.data, 'base64')
185
+ if (iv.length !== 12 || tag.length !== 16) throw new Error('Invalid encrypted file.')
186
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
187
+ decipher.setAAD(AAD)
188
+ decipher.setAuthTag(tag)
189
+ const plain = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
190
+ const parsed = JSON.parse(plain)
191
+ secretsCache = {
192
+ ...EMPTY_SECRETS(),
193
+ ...parsed,
194
+ providers: { ...(parsed.providers ?? {}) },
195
+ search: { ...(parsed.search ?? {}) },
196
+ deploy: {
197
+ cloudflare: { ...(parsed.deploy?.cloudflare ?? {}) },
198
+ netlify: { ...(parsed.deploy?.netlify ?? {}) },
199
+ vercel: { ...(parsed.deploy?.vercel ?? {}) },
200
+ },
201
+ }
202
+ return secretsCache
203
+ } catch (error) {
204
+ throw new Error(
205
+ `OpenSol could not decrypt its saved keys. Restore ${MASTER_FILE} or reset secrets from Settings → About. (${error instanceof Error ? error.message : 'unknown error'})`,
206
+ )
207
+ }
208
+ }
209
+
210
+ export async function loadSecretsSafe() {
211
+ try {
212
+ return { secrets: await loadSecrets(), error: '' }
213
+ } catch (error) {
214
+ return { secrets: EMPTY_SECRETS(), error: error instanceof Error ? error.message : 'Could not read saved keys.' }
215
+ }
216
+ }
217
+
218
+ export async function saveSecrets(next) {
219
+ await ensureDataDir()
220
+ const key = await masterKey()
221
+ const iv = randomBytes(12)
222
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
223
+ cipher.setAAD(AAD)
224
+ const data = Buffer.concat([cipher.update(JSON.stringify(next), 'utf8'), cipher.final()])
225
+ const tag = cipher.getAuthTag()
226
+ const envelope = {
227
+ version: 1,
228
+ algorithm: 'aes-256-gcm',
229
+ iv: iv.toString('base64'),
230
+ tag: tag.toString('base64'),
231
+ data: data.toString('base64'),
232
+ }
233
+ secretsCache = next
234
+ await enqueue('secrets', () => writeAtomic(SECRETS_FILE, envelope))
235
+ return next
236
+ }
237
+
238
+ export async function patchSecrets(patch) {
239
+ const secrets = await loadSecrets()
240
+ const next = {
241
+ ...secrets,
242
+ ...patch,
243
+ providers: { ...secrets.providers, ...(patch.providers ?? {}) },
244
+ search: { ...secrets.search, ...(patch.search ?? {}) },
245
+ deploy: {
246
+ cloudflare: { ...secrets.deploy.cloudflare, ...(patch.deploy?.cloudflare ?? {}) },
247
+ netlify: { ...secrets.deploy.netlify, ...(patch.deploy?.netlify ?? {}) },
248
+ vercel: { ...secrets.deploy.vercel, ...(patch.deploy?.vercel ?? {}) },
249
+ },
250
+ }
251
+ return saveSecrets(next)
252
+ }
253
+
254
+ export function hintFor(value) {
255
+ const text = typeof value === 'string' ? value.trim() : ''
256
+ if (!text) return ''
257
+ if (text.length <= 8) return '••••'
258
+ return `••••${text.slice(-4)}`
259
+ }
260
+
261
+ export function secretSummary(secrets) {
262
+ const providers = {}
263
+ for (const [id, value] of Object.entries(secrets.providers ?? {})) {
264
+ const apiKey = typeof value?.apiKey === 'string' ? value.apiKey : ''
265
+ providers[id] = { configured: Boolean(apiKey), hint: hintFor(apiKey) }
266
+ }
267
+ return {
268
+ providers,
269
+ search: {
270
+ configured: Boolean(secrets.search?.apiKey),
271
+ hint: hintFor(secrets.search?.apiKey),
272
+ },
273
+ deploy: {
274
+ cloudflare: {
275
+ configured: Boolean(secrets.deploy?.cloudflare?.apiToken && secrets.deploy?.cloudflare?.accountId),
276
+ hint: hintFor(secrets.deploy?.cloudflare?.apiToken),
277
+ fields: {
278
+ accountId: secrets.deploy?.cloudflare?.accountId ?? '',
279
+ },
280
+ },
281
+ netlify: {
282
+ configured: Boolean(secrets.deploy?.netlify?.authToken),
283
+ hint: hintFor(secrets.deploy?.netlify?.authToken),
284
+ fields: {
285
+ siteId: secrets.deploy?.netlify?.siteId ?? '',
286
+ },
287
+ },
288
+ vercel: {
289
+ configured: Boolean(secrets.deploy?.vercel?.token),
290
+ hint: hintFor(secrets.deploy?.vercel?.token),
291
+ fields: {
292
+ projectId: secrets.deploy?.vercel?.projectId ?? '',
293
+ teamId: secrets.deploy?.vercel?.teamId ?? '',
294
+ },
295
+ },
296
+ },
297
+ encryption: 'AES-256-GCM',
298
+ }
299
+ }
300
+
301
+ export async function providerKey(providerId) {
302
+ const { secrets } = await loadSecretsSafe()
303
+ return secrets.providers?.[providerId]?.apiKey ?? ''
304
+ }
305
+
306
+ export async function setProviderSecret(providerId, apiKey) {
307
+ const secrets = await loadSecrets()
308
+ const providers = { ...secrets.providers }
309
+ const clean = typeof apiKey === 'string' ? apiKey.trim() : ''
310
+ if (clean) providers[providerId] = { ...(providers[providerId] ?? {}), apiKey: clean }
311
+ else delete providers[providerId]
312
+ await saveSecrets({ ...secrets, providers })
313
+ return { configured: Boolean(clean), hint: hintFor(clean) }
314
+ }
315
+
316
+ export async function setSearchSecret(apiKey) {
317
+ const secrets = await loadSecrets()
318
+ const clean = typeof apiKey === 'string' ? apiKey.trim() : ''
319
+ await saveSecrets({ ...secrets, search: clean ? { apiKey: clean } : {} })
320
+ return { configured: Boolean(clean), hint: hintFor(clean) }
321
+ }
322
+
323
+ export async function setDeploySecret(provider, patch) {
324
+ if (!['cloudflare', 'netlify', 'vercel'].includes(provider)) {
325
+ throw new Error('Unknown deploy provider.')
326
+ }
327
+ const secrets = await loadSecrets()
328
+ const current = { ...(secrets.deploy?.[provider] ?? {}) }
329
+ const next = { ...current }
330
+ for (const [key, value] of Object.entries(patch ?? {})) {
331
+ if (typeof value !== 'string') continue
332
+ const clean = value.trim()
333
+ if (clean) next[key] = clean
334
+ else delete next[key]
335
+ }
336
+ const deploy = { ...secrets.deploy, [provider]: next }
337
+ await saveSecrets({ ...secrets, deploy })
338
+ return secretSummary({ ...secrets, deploy }).deploy[provider]
339
+ }
340
+
341
+ export async function addWorkspace(rawPath) {
342
+ const state = await loadState()
343
+ const resolved = path.resolve(rawPath)
344
+ const name = path.basename(resolved) || resolved
345
+ const entry = { path: resolved, name, lastOpened: Date.now() }
346
+ const workspaces = [entry, ...state.workspaces.filter((item) => item.path !== resolved)].slice(0, 12)
347
+ await saveState({ ...state, workspaces, activeWorkspace: resolved })
348
+ return entry
349
+ }
350
+
351
+ export async function forgetWorkspace() {
352
+ const state = await loadState()
353
+ await saveState({ ...state, activeWorkspace: null })
354
+ }
355
+
356
+ export async function saveSettings(settings) {
357
+ const state = await loadState()
358
+ await saveState({ ...state, settings })
359
+ }
360
+
361
+ export async function saveChats(chats) {
362
+ const state = await loadState()
363
+ await saveState({
364
+ ...state,
365
+ chats: {
366
+ sessions: Array.isArray(chats.sessions) ? chats.sessions : [],
367
+ activeId: typeof chats.activeId === 'string' ? chats.activeId : null,
368
+ },
369
+ })
370
+ }
371
+
372
+ export async function migrateLegacy(payload) {
373
+ const state = await loadState()
374
+ const legacySettings =
375
+ payload?.settings && typeof payload.settings === 'object'
376
+ ? payload.settings.state && typeof payload.settings.state === 'object'
377
+ ? payload.settings.state
378
+ : payload.settings
379
+ : {}
380
+ const legacyChats =
381
+ payload?.chats && typeof payload.chats === 'object'
382
+ ? Array.isArray(payload.chats.state?.sessions)
383
+ ? payload.chats.state
384
+ : payload.chats
385
+ : {}
386
+
387
+ let changed = false
388
+ const nextSettings = mergeLegacySettings(state.settings ?? {}, legacySettings)
389
+
390
+ const { secrets } = await loadSecretsSafe()
391
+ const nextSecrets = {
392
+ ...secrets,
393
+ providers: { ...(secrets.providers ?? {}) },
394
+ search: { ...(secrets.search ?? {}) },
395
+ deploy: {
396
+ cloudflare: { ...(secrets.deploy?.cloudflare ?? {}) },
397
+ netlify: { ...(secrets.deploy?.netlify ?? {}) },
398
+ vercel: { ...(secrets.deploy?.vercel ?? {}) },
399
+ },
400
+ }
401
+
402
+ for (const [provider, value] of Object.entries(legacySettings.providers ?? {})) {
403
+ if (!value || typeof value !== 'object') continue
404
+ const baseUrl = typeof value.baseUrl === 'string' ? value.baseUrl.trim() : ''
405
+ if (baseUrl) {
406
+ nextSettings.providers = {
407
+ ...(nextSettings.providers ?? {}),
408
+ [provider]: { ...(nextSettings.providers?.[provider] ?? {}), baseUrl },
409
+ }
410
+ }
411
+ const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : ''
412
+ if (apiKey && !nextSecrets.providers[provider]?.apiKey) {
413
+ nextSecrets.providers[provider] = { apiKey }
414
+ changed = true
415
+ }
416
+ }
417
+ const searchKey =
418
+ typeof legacySettings.searchApiKey === 'string' ? legacySettings.searchApiKey.trim() : ''
419
+ if (searchKey && !nextSecrets.search.apiKey) {
420
+ nextSecrets.search = { apiKey: searchKey }
421
+ changed = true
422
+ }
423
+
424
+ let addedChats = 0
425
+ const legacySessions = Array.isArray(legacyChats.sessions) ? legacyChats.sessions : []
426
+ let chats = state.chats
427
+ if (state.chats.sessions.length === 0 && legacySessions.length > 0) {
428
+ const activeId =
429
+ typeof legacyChats.activeId === 'string' &&
430
+ legacySessions.some((session) => session?.id === legacyChats.activeId)
431
+ ? legacyChats.activeId
432
+ : legacySessions[0]?.id ?? null
433
+ chats = { sessions: legacySessions, activeId }
434
+ addedChats = legacySessions.length
435
+ changed = true
436
+ }
437
+
438
+ const settingsChanged =
439
+ JSON.stringify(nextSettings) !== JSON.stringify(state.settings ?? {})
440
+ if (changed) await saveSecrets(nextSecrets)
441
+ const next = {
442
+ ...state,
443
+ settings: nextSettings,
444
+ chats,
445
+ legacyMigratedAt: state.legacyMigratedAt ?? new Date().toISOString(),
446
+ }
447
+ if (settingsChanged || addedChats > 0 || !state.legacyMigratedAt) await saveState(next)
448
+ return {
449
+ migratedSettings: settingsChanged,
450
+ migratedChats: addedChats,
451
+ migratedSecrets: changed,
452
+ }
453
+ }
454
+
455
+ export async function resetAll({ keepMaster = true } = {}) {
456
+ stateCache = null
457
+ secretsCache = null
458
+ await rm(STATE_FILE, { force: true })
459
+ await rm(SECRETS_FILE, { force: true })
460
+ if (!keepMaster) {
461
+ masterKeyCache = null
462
+ await rm(MASTER_FILE, { force: true })
463
+ }
464
+ await rm(MODELS_CACHE, { force: true })
465
+ const fresh = DEFAULT_STATE()
466
+ await saveState(fresh)
467
+ return fresh
468
+ }
469
+
470
+ export async function resetSecrets() {
471
+ secretsCache = null
472
+ masterKeyCache = null
473
+ await rm(SECRETS_FILE, { force: true })
474
+ await rm(MASTER_FILE, { force: true })
475
+ const empty = EMPTY_SECRETS()
476
+ await saveSecrets(empty)
477
+ return empty
478
+ }
479
+
480
+ export async function loadModelsCache() {
481
+ return readJson(MODELS_CACHE, null)
482
+ }
483
+
484
+ export async function saveModelsCache(value) {
485
+ await ensureDataDir()
486
+ await enqueue('models-cache', () => writeAtomic(MODELS_CACHE, value, 0o600))
487
+ }
488
+
489
+ export function dataPaths() {
490
+ return { dataDir: DATA_DIR, stateFile: STATE_FILE, secretsFile: SECRETS_FILE, masterFile: MASTER_FILE }
491
+ }