@shawnstack/quickforge 1.0.0 → 1.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 (71) hide show
  1. package/README.md +22 -16
  2. package/bin/quickforge.mjs +83 -8
  3. package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +2 -1
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +326 -34
  27. package/server/project-config.mjs +85 -55
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +49 -19
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +66 -12
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +578 -133
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +31 -1
  52. package/server/utils/response.mjs +9 -2
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-BQJ8qi1U.css +0 -3
  65. package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
  66. package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
  67. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  68. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  69. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  70. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  71. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -8,210 +8,655 @@ export const stores = new Set([
8
8
  'custom-providers',
9
9
  'sessions',
10
10
  'sessions-metadata',
11
+ 'scheduled-tasks',
11
12
  ])
12
13
 
13
- function platformDataDir(appName) {
14
- if (process.platform === 'win32') {
15
- return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), appName)
16
- }
17
- if (process.platform === 'darwin') {
18
- return path.join(os.homedir(), 'Library', 'Application Support', appName)
19
- }
20
- return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName)
14
+ // --- In-memory session bucket index ---
15
+ // Avoids O(n) directory scanning in findSessionBucket() by caching
16
+ // sessionId { scope, projectId } lookups. Populated lazily on first
17
+ // lookup and kept up-to-date by write/delete paths.
18
+ /** @type {Map<string, { scope: string, projectId?: string }>} */
19
+ const sessionBucketIndex = new Map()
20
+ let bucketIndexBuilt = false
21
+
22
+ const configStores = new Set(['settings', 'provider-keys', 'custom-providers'])
23
+ const sessionStores = new Set(['sessions', 'sessions-metadata'])
24
+
25
+ const configStoreSections = {
26
+ settings: ['app', 'settings'],
27
+ 'provider-keys': ['credentials', 'providerKeys'],
28
+ 'custom-providers': ['providers', 'customProviders'],
21
29
  }
22
30
 
23
31
  export function getDataDir() {
24
32
  if (process.env.QUICKFORGE_DATA_DIR) return path.resolve(process.env.QUICKFORGE_DATA_DIR)
25
- if (process.env.FASTCODE_DATA_DIR) return path.resolve(process.env.FASTCODE_DATA_DIR)
26
33
  return path.join(os.homedir(), '.quickforge')
27
34
  }
28
35
 
29
36
  export const dataDir = getDataDir()
37
+ export const configDir = path.join(dataDir, 'config')
30
38
  export const storageDir = path.join(dataDir, 'storage')
39
+ export const cacheDir = path.join(dataDir, 'cache')
40
+ export const logsDir = path.join(dataDir, 'logs')
41
+
42
+ const quickForgeConfigFile = path.join(configDir, 'config.json')
43
+ const configMigrationMarkerFile = path.join(configDir, '.layout-migrated')
44
+ const legacyStorageMigrationMarkerFile = path.join(storageDir, '.layout-migrated')
31
45
 
32
46
  export function storeFile(storeName) {
33
- return path.join(storageDir, `${storeName}.json`)
47
+ assertStore(storeName)
48
+ if (configStores.has(storeName)) return quickForgeConfigFile
49
+ return sessionStoreFile(storeName, { scope: 'global' })
50
+ }
51
+
52
+ export function configFile() {
53
+ return quickForgeConfigFile
34
54
  }
35
55
 
56
+ // Compatibility export for older modules/imports. Project config now lives inside config/config.json -> projects.
36
57
  export function projectConfigFile() {
58
+ return quickForgeConfigFile
59
+ }
60
+
61
+ function legacyFlatStoreFile(storeName) {
62
+ return path.join(storageDir, `${storeName}.json`)
63
+ }
64
+
65
+ function legacyNestedStoreFile(storeName) {
66
+ const paths = {
67
+ settings: ['config', 'settings.json'],
68
+ 'provider-keys': ['credentials', 'provider-keys.json'],
69
+ 'custom-providers': ['providers', 'custom-providers.json'],
70
+ }
71
+ return path.join(storageDir, ...paths[storeName])
72
+ }
73
+
74
+ function legacyFlatProjectConfigFile() {
37
75
  return path.join(storageDir, 'project.json')
38
76
  }
39
77
 
40
- const writeQueues = new Map()
78
+ function legacyNestedProjectConfigFile() {
79
+ return path.join(storageDir, 'projects', 'project.json')
80
+ }
41
81
 
42
- export async function ensureStorage() {
43
- await fs.mkdir(storageDir, { recursive: true })
44
- await Promise.all(
45
- [...stores].map(async (store) => {
46
- const file = storeFile(store)
47
- if (!existsSync(file)) await fs.writeFile(file, '{}\n', 'utf8')
48
- }),
49
- )
82
+ function defaultProjectConfig() {
83
+ return {
84
+ activeProjectId: null,
85
+ globalSkills: [],
86
+ projects: [],
87
+ }
50
88
  }
51
89
 
52
- export async function readStore(storeName) {
53
- assertStore(storeName)
54
- await ensureStorage()
55
- const file = storeFile(storeName)
90
+ function defaultConfig() {
91
+ return {
92
+ layoutVersion: 1,
93
+ updatedAt: new Date().toISOString(),
94
+ app: {
95
+ settings: {},
96
+ },
97
+ providers: {
98
+ customProviders: {},
99
+ },
100
+ credentials: {
101
+ providerKeys: {},
102
+ },
103
+ projects: defaultProjectConfig(),
104
+ }
105
+ }
106
+
107
+ function normalizeStringArray(value) {
108
+ if (!Array.isArray(value)) return []
109
+ const result = []
110
+ const seen = new Set()
111
+ for (const item of value) {
112
+ if (typeof item !== 'string') continue
113
+ const text = item.trim()
114
+ if (!text || seen.has(text)) continue
115
+ seen.add(text)
116
+ result.push(text)
117
+ }
118
+ return result
119
+ }
120
+
121
+ function normalizeProjectConfig(value) {
122
+ const base = defaultProjectConfig()
123
+ if (!value || typeof value !== 'object') return base
124
+ const projects = Array.isArray(value.projects)
125
+ ? value.projects.map((project) => ({
126
+ ...project,
127
+ skills: normalizeStringArray(project?.skills),
128
+ }))
129
+ : base.projects
130
+ return {
131
+ activeProjectId: typeof value.activeProjectId === 'string' ? value.activeProjectId : base.activeProjectId,
132
+ globalSkills: normalizeStringArray(value.globalSkills),
133
+ projects,
134
+ }
135
+ }
136
+
137
+ function normalizeConfig(value) {
138
+ const base = defaultConfig()
139
+ const input = value && typeof value === 'object' ? value : {}
140
+
141
+ return {
142
+ ...input,
143
+ layoutVersion: Number(input.layoutVersion || base.layoutVersion),
144
+ updatedAt: typeof input.updatedAt === 'string' ? input.updatedAt : base.updatedAt,
145
+ app: {
146
+ ...(input.app && typeof input.app === 'object' ? input.app : {}),
147
+ settings:
148
+ input.app?.settings && typeof input.app.settings === 'object'
149
+ ? input.app.settings
150
+ : base.app.settings,
151
+ },
152
+ providers: {
153
+ ...(input.providers && typeof input.providers === 'object' ? input.providers : {}),
154
+ customProviders:
155
+ input.providers?.customProviders && typeof input.providers.customProviders === 'object'
156
+ ? input.providers.customProviders
157
+ : base.providers.customProviders,
158
+ },
159
+ credentials: {
160
+ ...(input.credentials && typeof input.credentials === 'object' ? input.credentials : {}),
161
+ providerKeys:
162
+ input.credentials?.providerKeys && typeof input.credentials.providerKeys === 'object'
163
+ ? input.credentials.providerKeys
164
+ : base.credentials.providerKeys,
165
+ },
166
+ projects: normalizeProjectConfig(input.projects),
167
+ }
168
+ }
169
+
170
+ function configSection(config, storeName) {
171
+ const [section, key] = configStoreSections[storeName]
172
+ return config?.[section]?.[key] && typeof config[section][key] === 'object' ? config[section][key] : {}
173
+ }
174
+
175
+ function setConfigSection(config, storeName, data) {
176
+ const [section, key] = configStoreSections[storeName]
177
+ config[section] = config[section] && typeof config[section] === 'object' ? config[section] : {}
178
+ config[section][key] = data && typeof data === 'object' ? data : {}
179
+ }
180
+
181
+ function sessionBucket(value) {
182
+ if (value?.scope === 'project' && value?.projectId) {
183
+ return { scope: 'project', projectId: String(value.projectId) }
184
+ }
185
+ return { scope: 'global' }
186
+ }
187
+
188
+ function assertSafePathSegment(segment) {
189
+ if (!segment || segment === '.' || segment === '..' || /[\\/]/.test(segment)) {
190
+ const error = new Error(`Invalid path segment: ${segment}`)
191
+ error.statusCode = 400
192
+ throw error
193
+ }
194
+ }
195
+
196
+ function sessionStoreFile(storeName, bucket) {
197
+ if (bucket.scope === 'project') {
198
+ assertSafePathSegment(bucket.projectId)
199
+ return path.join(storageDir, 'conversations', 'projects', bucket.projectId, `${storeName}.json`)
200
+ }
201
+ return path.join(storageDir, 'conversations', 'global', `${storeName}.json`)
202
+ }
203
+
204
+ function sessionDataDir(bucket) {
205
+ if (bucket.scope === 'project') {
206
+ assertSafePathSegment(bucket.projectId)
207
+ return path.join(storageDir, 'conversations', 'projects', bucket.projectId, 'sessions')
208
+ }
209
+ return path.join(storageDir, 'conversations', 'global', 'sessions')
210
+ }
211
+
212
+ function sessionDataFile(sessionId, bucket) {
213
+ assertSafePathSegment(sessionId)
214
+ return path.join(sessionDataDir(bucket), `${sessionId}.json`)
215
+ }
216
+
217
+ export async function ensureProjectCache(projectId) {
218
+ const safeProjectId = String(projectId || '')
219
+ assertSafePathSegment(safeProjectId)
220
+ const projectCacheDir = path.join(cacheDir, 'projects', safeProjectId)
221
+ const projectStorageDir = path.join(storageDir, 'conversations', 'projects', safeProjectId)
222
+
223
+ await Promise.all([
224
+ fs.mkdir(path.join(projectCacheDir, 'workspace', 'file-index'), { recursive: true }),
225
+ fs.mkdir(path.join(projectCacheDir, 'workspace', 'grep'), { recursive: true }),
226
+ fs.mkdir(path.join(projectCacheDir, 'llm', 'responses'), { recursive: true }),
227
+ fs.mkdir(path.join(projectCacheDir, 'llm', 'reasoning'), { recursive: true }),
228
+ fs.mkdir(path.join(projectCacheDir, 'assets'), { recursive: true }),
229
+ fs.mkdir(path.join(projectCacheDir, 'tmp'), { recursive: true }),
230
+ fs.mkdir(path.join(projectStorageDir, 'sessions'), { recursive: true }),
231
+ ensureJsonFile(path.join(projectStorageDir, 'sessions-metadata.json')),
232
+ ])
233
+
234
+ return projectCacheDir
235
+ }
236
+
237
+ async function ensureJsonFile(file, defaultValue = {}) {
238
+ await fs.mkdir(path.dirname(file), { recursive: true })
239
+ if (!existsSync(file)) await fs.writeFile(file, `${JSON.stringify(defaultValue, null, 2)}\n`, 'utf8')
240
+ }
241
+
242
+ async function readJsonFile(file, defaultValue = {}) {
56
243
  try {
57
244
  const text = await fs.readFile(file, 'utf8')
58
- return text.trim() ? JSON.parse(text) : {}
245
+ const json = text.trimStart()
246
+ return json ? JSON.parse(json) : defaultValue
59
247
  } catch (error) {
60
- if (error?.code === 'ENOENT') return {}
248
+ if (error?.code === 'ENOENT') return defaultValue
61
249
  throw error
62
250
  }
63
251
  }
64
252
 
65
- export async function writeStore(storeName, data) {
66
- assertStore(storeName)
67
- const previous = writeQueues.get(storeName) || Promise.resolve()
68
- const next = previous
69
- .catch(() => undefined)
70
- .then(async () => {
71
- await ensureStorage()
72
- const file = storeFile(storeName)
73
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
74
- await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
75
- await fs.rename(tmp, file)
76
- })
77
- writeQueues.set(storeName, next)
78
- return next
253
+ async function writeJsonAtomic(file, data) {
254
+ await fs.mkdir(path.dirname(file), { recursive: true })
255
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
256
+ await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
257
+ await fs.rename(tmp, file)
79
258
  }
80
259
 
81
- function assertStore(storeName) {
82
- if (!stores.has(storeName)) {
83
- const error = new Error(`Unknown storage store: ${storeName}`)
84
- error.statusCode = 404
85
- throw error
86
- }
260
+ async function readConfigFile() {
261
+ return normalizeConfig(await readJsonFile(quickForgeConfigFile, defaultConfig()))
87
262
  }
88
263
 
89
- export function getComparable(value, key) {
90
- if (!value || typeof value !== 'object') return undefined
91
- return key.split('.').reduce((current, part) => {
92
- if (!current || typeof current !== 'object') return undefined
93
- return current[part]
94
- }, value)
264
+ async function writeConfigFile(config) {
265
+ const next = normalizeConfig(config)
266
+ next.layoutVersion = 1
267
+ next.updatedAt = new Date().toISOString()
268
+ await writeJsonAtomic(quickForgeConfigFile, next)
95
269
  }
96
270
 
97
- export async function directoryExists(file) {
271
+ async function listProjectSessionFiles(storeName) {
272
+ const projectsDir = path.join(storageDir, 'conversations', 'projects')
273
+ let entries = []
98
274
  try {
99
- await fs.access(file)
100
- return true
101
- } catch {
102
- return false
275
+ entries = await fs.readdir(projectsDir, { withFileTypes: true })
276
+ } catch (error) {
277
+ if (error?.code !== 'ENOENT') throw error
103
278
  }
279
+
280
+ return entries
281
+ .filter((entry) => entry.isDirectory())
282
+ .map((entry) => path.join(projectsDir, entry.name, `${storeName}.json`))
104
283
  }
105
284
 
106
- export async function readJsonObject(file) {
285
+ async function listProjectIds() {
286
+ const projectsDir = path.join(storageDir, 'conversations', 'projects')
287
+ let entries = []
107
288
  try {
108
- const text = await fs.readFile(file, 'utf8')
109
- const parsed = text.trim() ? JSON.parse(text) : {}
110
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
111
- } catch {
112
- return null
289
+ entries = await fs.readdir(projectsDir, { withFileTypes: true })
290
+ } catch (error) {
291
+ if (error?.code !== 'ENOENT') throw error
113
292
  }
293
+
294
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
114
295
  }
115
296
 
116
- export async function mergeJsonObjectFile(sourceFile, targetFile) {
117
- const source = await readJsonObject(sourceFile)
118
- if (!source) return false
297
+ async function listSessionDataFiles(bucket) {
298
+ const dir = sessionDataDir(bucket)
299
+ let entries = []
300
+ try {
301
+ entries = await fs.readdir(dir, { withFileTypes: true })
302
+ } catch (error) {
303
+ if (error?.code !== 'ENOENT') throw error
304
+ }
119
305
 
120
- const target = (await readJsonObject(targetFile)) ?? {}
121
- let changed = false
122
- for (const [key, value] of Object.entries(source)) {
123
- if (Object.hasOwn(target, key)) continue
124
- target[key] = value
125
- changed = true
306
+ return entries
307
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
308
+ .map((entry) => path.join(dir, entry.name))
309
+ }
310
+
311
+ async function readSessionValuesScoped(scope, projectId) {
312
+ const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
313
+ const files = await listSessionDataFiles(bucket)
314
+ const result = {}
315
+ for (const file of files) {
316
+ const value = await readJsonFile(file, null)
317
+ if (value?.id) result[value.id] = value
126
318
  }
319
+ return result
320
+ }
127
321
 
128
- if (!changed) return false
322
+ async function readAllSessionValues() {
323
+ const result = await readSessionValuesScoped('global')
324
+ for (const projectId of await listProjectIds()) {
325
+ Object.assign(result, await readSessionValuesScoped('project', projectId))
326
+ }
327
+ return result
328
+ }
129
329
 
130
- await fs.mkdir(path.dirname(targetFile), { recursive: true })
131
- const tmp = `${targetFile}.${process.pid}.${Date.now()}.tmp`
132
- await fs.writeFile(tmp, `${JSON.stringify(target, null, 2)}\n`, 'utf8')
133
- await fs.rename(tmp, targetFile)
134
- return true
330
+ async function writeSessionValueFile(sessionId, value) {
331
+ await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
332
+ // Keep in-memory index current
333
+ if (value) sessionBucketIndex.set(sessionId, sessionBucket(value))
135
334
  }
136
335
 
137
- export async function copyMissingRecursive(source, target) {
138
- const stat = await fs.stat(source).catch(() => null)
139
- if (!stat) return false
336
+ async function writeSessionValues(data) {
337
+ const nextIds = new Set(Object.keys(data || {}))
338
+ const existingFiles = [
339
+ ...(await listSessionDataFiles({ scope: 'global' })),
340
+ ...(await Promise.all(
341
+ (await listProjectIds()).map((projectId) => listSessionDataFiles({ scope: 'project', projectId })),
342
+ )).flat(),
343
+ ]
344
+
345
+ await Promise.all(
346
+ existingFiles.map(async (file) => {
347
+ const sessionId = path.basename(file, '.json')
348
+ if (!nextIds.has(sessionId)) {
349
+ await fs.rm(file, { force: true })
350
+ sessionBucketIndex.delete(sessionId)
351
+ }
352
+ }),
353
+ )
354
+
355
+ await Promise.all(
356
+ Object.entries(data || {}).map(([sessionId, value]) => writeSessionValueFile(sessionId, value)),
357
+ )
358
+ }
140
359
 
141
- if (stat.isDirectory()) {
142
- await fs.mkdir(target, { recursive: true })
143
- let copied = false
144
- const entries = await fs.readdir(source, { withFileTypes: true })
145
- for (const entry of entries) {
146
- copied = (await copyMissingRecursive(path.join(source, entry.name), path.join(target, entry.name))) || copied
360
+ async function rebuildBucketIndex() {
361
+ sessionBucketIndex.clear()
362
+ // Global bucket
363
+ try {
364
+ const globalMeta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' }), {})
365
+ for (const [id, meta] of Object.entries(globalMeta)) {
366
+ if (meta && typeof meta === 'object') sessionBucketIndex.set(id, sessionBucket(meta))
147
367
  }
148
- return copied
368
+ } catch { /* ignore */ }
369
+ // Project buckets
370
+ for (const projectId of await listProjectIds()) {
371
+ try {
372
+ const meta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'project', projectId }), {})
373
+ for (const [id, entry] of Object.entries(meta)) {
374
+ if (entry && typeof entry === 'object') sessionBucketIndex.set(id, sessionBucket(entry))
375
+ }
376
+ } catch { /* ignore */ }
149
377
  }
378
+ bucketIndexBuilt = true
379
+ }
150
380
 
151
- if (!stat.isFile() || (await directoryExists(target))) return false
381
+ export async function findSessionBucket(sessionId) {
382
+ if (!bucketIndexBuilt) {
383
+ await ensureStorage()
384
+ await rebuildBucketIndex()
385
+ }
386
+ return sessionBucketIndex.get(sessionId) ?? null
387
+ }
152
388
 
153
- await fs.mkdir(path.dirname(target), { recursive: true })
154
- await fs.copyFile(source, target)
155
- return true
389
+ export async function readSessionValue(sessionId) {
390
+ const bucket = await findSessionBucket(sessionId)
391
+ if (!bucket) return null
392
+ return readJsonFile(sessionDataFile(sessionId, bucket), null)
156
393
  }
157
394
 
158
- export function uniquePaths(paths) {
159
- const seen = new Set()
160
- return paths.filter((item) => {
161
- const resolved = path.resolve(item)
162
- const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved
163
- if (seen.has(key)) return false
164
- seen.add(key)
165
- return true
395
+ export async function writeSessionValue(sessionId, value) {
396
+ return enqueueWrite('sessions', async () => {
397
+ await ensureStorage()
398
+ await writeSessionValueFile(sessionId, value)
166
399
  })
167
400
  }
168
401
 
169
- export async function migrateLegacyDataDir(sourceDir) {
170
- const resolvedSource = path.resolve(sourceDir)
171
- const resolvedTarget = path.resolve(dataDir)
172
- const sourceKey = process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource
173
- const targetKey = process.platform === 'win32' ? resolvedTarget.toLowerCase() : resolvedTarget
174
- if (sourceKey === targetKey) return false
402
+ export async function deleteSessionValue(sessionId) {
403
+ return enqueueWrite('sessions', async () => {
404
+ const bucket = await findSessionBucket(sessionId)
405
+ if (!bucket) return
406
+ await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
407
+ sessionBucketIndex.delete(sessionId)
408
+ })
409
+ }
175
410
 
176
- const sourceStorageDir = path.join(resolvedSource, 'storage')
177
- if (!(await directoryExists(sourceStorageDir))) return false
411
+ async function listSessionStoreFiles(storeName) {
412
+ return [
413
+ sessionStoreFile(storeName, { scope: 'global' }),
414
+ ...(await listProjectSessionFiles(storeName)),
415
+ ]
416
+ }
178
417
 
179
- await fs.mkdir(storageDir, { recursive: true })
418
+ async function readSessionStore(storeName) {
419
+ if (storeName === 'sessions') return readAllSessionValues()
420
+
421
+ const files = await listSessionStoreFiles(storeName)
422
+ const result = {}
423
+ for (const file of files) {
424
+ Object.assign(result, await readJsonFile(file, {}))
425
+ }
426
+ return result
427
+ }
428
+
429
+ export async function readSessionStoreScoped(storeName, scope, projectId) {
430
+ await ensureStorage()
431
+ if (storeName === 'sessions') return readSessionValuesScoped(scope, projectId)
432
+
433
+ const file = sessionStoreFile(storeName, { scope, projectId })
434
+ return readJsonFile(file, {})
435
+ }
436
+
437
+ async function writeSessionStore(storeName, data) {
438
+ if (storeName === 'sessions') {
439
+ await writeSessionValues(data)
440
+ return
441
+ }
442
+
443
+ const buckets = new Map()
444
+
445
+ for (const [key, value] of Object.entries(data || {})) {
446
+ const bucket = sessionBucket(value)
447
+ const bucketKey = bucket.scope === 'project' ? `project:${bucket.projectId}` : 'global'
448
+ if (!buckets.has(bucketKey)) buckets.set(bucketKey, { bucket, data: {} })
449
+ buckets.get(bucketKey).data[key] = value
450
+ }
180
451
 
181
- let migrated = false
182
- for (const store of stores) {
183
- const sourceFile = path.join(sourceStorageDir, `${store}.json`)
184
- const targetFile = storeFile(store)
185
- if (!(await directoryExists(sourceFile))) continue
186
- if (await directoryExists(targetFile)) {
187
- migrated = (await mergeJsonObjectFile(sourceFile, targetFile)) || migrated
188
- } else {
189
- await fs.copyFile(sourceFile, targetFile)
190
- migrated = true
452
+ const filesToWrite = new Set(await listSessionStoreFiles(storeName))
453
+ for (const { bucket } of buckets.values()) {
454
+ filesToWrite.add(sessionStoreFile(storeName, bucket))
455
+ }
456
+
457
+ await Promise.all(
458
+ [...filesToWrite].map(async (file) => {
459
+ const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
460
+ await writeJsonAtomic(file, bucketEntry?.data ?? {})
461
+ }),
462
+ )
463
+
464
+ // Keep in-memory bucket index current for metadata writes
465
+ if (storeName === 'sessions-metadata') {
466
+ for (const [sessionId, meta] of Object.entries(data || {})) {
467
+ if (meta && typeof meta === 'object') sessionBucketIndex.set(sessionId, sessionBucket(meta))
191
468
  }
192
469
  }
470
+ }
471
+
472
+ async function migrateLegacySessionStore(storeName) {
473
+ const file = legacyFlatStoreFile(storeName)
474
+ if (!existsSync(file)) return
475
+
476
+ const legacy = await readJsonFile(file, {})
477
+ const current = await readSessionStore(storeName)
478
+ const merged = { ...legacy, ...current }
479
+ await writeSessionStore(storeName, merged)
480
+ }
481
+
482
+ async function migrateUnifiedConfig() {
483
+ if (existsSync(configMigrationMarkerFile)) return
484
+
485
+ const current = await readConfigFile()
486
+ const flatSettings = await readJsonFile(legacyFlatStoreFile('settings'), {})
487
+ const nestedSettings = await readJsonFile(legacyNestedStoreFile('settings'), {})
488
+ const flatProviderKeys = await readJsonFile(legacyFlatStoreFile('provider-keys'), {})
489
+ const nestedProviderKeys = await readJsonFile(legacyNestedStoreFile('provider-keys'), {})
490
+ const flatCustomProviders = await readJsonFile(legacyFlatStoreFile('custom-providers'), {})
491
+ const nestedCustomProviders = await readJsonFile(legacyNestedStoreFile('custom-providers'), {})
492
+ const flatProjects = normalizeProjectConfig(await readJsonFile(legacyFlatProjectConfigFile(), defaultProjectConfig()))
493
+ const nestedProjects = normalizeProjectConfig(await readJsonFile(legacyNestedProjectConfigFile(), defaultProjectConfig()))
494
+
495
+ current.app.settings = {
496
+ ...flatSettings,
497
+ ...nestedSettings,
498
+ ...current.app.settings,
499
+ }
500
+ current.credentials.providerKeys = {
501
+ ...flatProviderKeys,
502
+ ...nestedProviderKeys,
503
+ ...current.credentials.providerKeys,
504
+ }
505
+ current.providers.customProviders = {
506
+ ...flatCustomProviders,
507
+ ...nestedCustomProviders,
508
+ ...current.providers.customProviders,
509
+ }
510
+
511
+ if (current.projects.projects.length === 0) {
512
+ current.projects = nestedProjects.projects.length > 0 ? nestedProjects : flatProjects
513
+ }
514
+
515
+ await writeConfigFile(current)
193
516
 
194
- const sourceProjectFile = path.join(sourceStorageDir, 'project.json')
195
- if ((await directoryExists(sourceProjectFile)) && !(await directoryExists(projectConfigFile()))) {
196
- await fs.copyFile(sourceProjectFile, projectConfigFile())
197
- migrated = true
517
+ if (!existsSync(legacyStorageMigrationMarkerFile)) {
518
+ await migrateLegacySessionStore('sessions-metadata')
519
+ await fs.mkdir(path.dirname(legacyStorageMigrationMarkerFile), { recursive: true })
520
+ await fs.writeFile(legacyStorageMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
198
521
  }
199
522
 
200
- migrated = (await copyMissingRecursive(sourceStorageDir, storageDir)) || migrated
201
- if (migrated) console.log(`Migrated legacy QuickForge data from ${resolvedSource} to ${resolvedTarget}`)
202
- return migrated
523
+ await fs.mkdir(path.dirname(configMigrationMarkerFile), { recursive: true })
524
+ await fs.writeFile(configMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
525
+ }
526
+
527
+ const writeQueues = new Map()
528
+
529
+ function enqueueWrite(queueName, operation) {
530
+ const previous = writeQueues.get(queueName) || Promise.resolve()
531
+ const next = previous
532
+ .catch(() => undefined)
533
+ .then(operation)
534
+ writeQueues.set(queueName, next)
535
+ return next
536
+ }
537
+
538
+ /**
539
+ * Atomically read-modify-write a store within its serialized write queue.
540
+ * Eliminates the race condition where concurrent read-modify-write operations
541
+ * from multiple browser tabs would overwrite each other.
542
+ *
543
+ * @param {string} storeName
544
+ * @param {(data: object) => object} updateFn — receives current data, returns updated data
545
+ * @returns {Promise<object>} the updated data
546
+ */
547
+ export async function atomicUpdate(storeName, updateFn) {
548
+ assertStore(storeName)
549
+ const queueName = configStores.has(storeName) ? 'config' : storeName
550
+ return enqueueWrite(queueName, async () => {
551
+ await ensureStorage()
552
+ if (configStores.has(storeName)) {
553
+ const config = await readConfigFile()
554
+ const data = configSection(config, storeName)
555
+ const updated = updateFn(data)
556
+ setConfigSection(config, storeName, updated)
557
+ await writeConfigFile(config)
558
+ return updated
559
+ }
560
+ const data = await readSessionStore(storeName)
561
+ const updated = updateFn(data)
562
+ await writeSessionStore(storeName, updated)
563
+ return updated
564
+ })
203
565
  }
204
566
 
205
- export async function migrateLegacyDataDirs() {
206
- if (process.env.QUICKFORGE_DATA_DIR || process.env.FASTCODE_DATA_DIR) return
567
+ /**
568
+ * Atomically read-modify-write the project config within the config queue.
569
+ */
570
+ export async function atomicProjectConfigUpdate(updateFn) {
571
+ return enqueueWrite('config', async () => {
572
+ await ensureStorage()
573
+ const config = await readConfigFile()
574
+ const projectConfig = normalizeProjectConfig(config.projects)
575
+ const updated = updateFn(projectConfig)
576
+ config.projects = normalizeProjectConfig(updated)
577
+ await writeConfigFile(config)
578
+ return updated
579
+ })
580
+ }
207
581
 
208
- const legacyDirs = uniquePaths([
209
- platformDataDir('QuickForge'),
210
- platformDataDir('FastCode'),
211
- path.join(os.homedir(), '.fastcode'),
582
+ export async function ensureStorage() {
583
+ await fs.mkdir(configDir, { recursive: true })
584
+ await fs.mkdir(storageDir, { recursive: true })
585
+ await fs.mkdir(cacheDir, { recursive: true })
586
+ await fs.mkdir(logsDir, { recursive: true })
587
+ await Promise.all([
588
+ fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
589
+ fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
590
+ fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
591
+ fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
592
+ fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
593
+ ])
594
+
595
+ await migrateUnifiedConfig()
596
+
597
+ await Promise.all([
598
+ ensureJsonFile(quickForgeConfigFile, defaultConfig()),
599
+ ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
212
600
  ])
601
+ }
213
602
 
214
- for (const dir of legacyDirs) {
215
- await migrateLegacyDataDir(dir)
603
+ export async function readStore(storeName) {
604
+ assertStore(storeName)
605
+ await ensureStorage()
606
+
607
+ if (configStores.has(storeName)) {
608
+ const config = await readConfigFile()
609
+ return configSection(config, storeName)
216
610
  }
611
+
612
+ return readSessionStore(storeName)
613
+ }
614
+
615
+ export async function writeStore(storeName, data) {
616
+ assertStore(storeName)
617
+ const queueName = configStores.has(storeName) ? 'config' : storeName
618
+
619
+ return enqueueWrite(queueName, async () => {
620
+ await ensureStorage()
621
+
622
+ if (configStores.has(storeName)) {
623
+ const config = await readConfigFile()
624
+ setConfigSection(config, storeName, data)
625
+ await writeConfigFile(config)
626
+ return
627
+ }
628
+
629
+ await writeSessionStore(storeName, data)
630
+ })
631
+ }
632
+
633
+ export async function readProjectConfigData() {
634
+ await ensureStorage()
635
+ const config = await readConfigFile()
636
+ return normalizeProjectConfig(config.projects)
637
+ }
638
+
639
+ export async function writeProjectConfigData(projectConfig) {
640
+ return enqueueWrite('config', async () => {
641
+ await ensureStorage()
642
+ const config = await readConfigFile()
643
+ config.projects = normalizeProjectConfig(projectConfig)
644
+ await writeConfigFile(config)
645
+ })
646
+ }
647
+
648
+ function assertStore(storeName) {
649
+ if (!stores.has(storeName)) {
650
+ const error = new Error(`Unknown storage store: ${storeName}`)
651
+ error.statusCode = 404
652
+ throw error
653
+ }
654
+ }
655
+
656
+ export function getComparable(value, key) {
657
+ if (!value || typeof value !== 'object') return undefined
658
+ return key.split('.').reduce((current, part) => {
659
+ if (!current || typeof current !== 'object') return undefined
660
+ return current[part]
661
+ }, value)
217
662
  }