@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.
- package/README.md +22 -16
- package/bin/quickforge.mjs +83 -8
- package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +2 -1
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +326 -34
- package/server/project-config.mjs +85 -55
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +49 -19
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +66 -12
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +578 -133
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +31 -1
- package/server/utils/response.mjs +9 -2
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-BQJ8qi1U.css +0 -3
- package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
- package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
package/server/storage.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
function legacyNestedProjectConfigFile() {
|
|
79
|
+
return path.join(storageDir, 'projects', 'project.json')
|
|
80
|
+
}
|
|
41
81
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
[
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}),
|
|
49
|
-
)
|
|
82
|
+
function defaultProjectConfig() {
|
|
83
|
+
return {
|
|
84
|
+
activeProjectId: null,
|
|
85
|
+
globalSkills: [],
|
|
86
|
+
projects: [],
|
|
87
|
+
}
|
|
50
88
|
}
|
|
51
89
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
271
|
+
async function listProjectSessionFiles(storeName) {
|
|
272
|
+
const projectsDir = path.join(storageDir, 'conversations', 'projects')
|
|
273
|
+
let entries = []
|
|
98
274
|
try {
|
|
99
|
-
await fs.
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
285
|
+
async function listProjectIds() {
|
|
286
|
+
const projectsDir = path.join(storageDir, 'conversations', 'projects')
|
|
287
|
+
let entries = []
|
|
107
288
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
await
|
|
155
|
-
return
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
411
|
+
async function listSessionStoreFiles(storeName) {
|
|
412
|
+
return [
|
|
413
|
+
sessionStoreFile(storeName, { scope: 'global' }),
|
|
414
|
+
...(await listProjectSessionFiles(storeName)),
|
|
415
|
+
]
|
|
416
|
+
}
|
|
178
417
|
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
await
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
await fs.
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
}
|