@shawnstack/quickforge 1.1.0 → 1.2.1
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 +1 -1
- package/bin/quickforge.mjs +72 -7
- package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-D93MV5Cx.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-DoraECXN.js +3187 -0
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-_z7sua8J.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 +14 -13
- 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 +322 -32
- package/server/project-config.mjs +80 -31
- 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 +46 -10
- 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 +58 -10
- 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 +247 -6
- 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 +30 -0
- package/server/utils/response.mjs +8 -1
- 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-4mZt9XEq.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-Bq6VHkyY.js +0 -3048
- package/dist/assets/index-D7uXa1RT.css +0 -3
- package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
- package/dist/assets/prompt-dialog-BGMKszUz.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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
+
import {
|
|
3
|
+
createAgent,
|
|
4
|
+
runPrompt,
|
|
5
|
+
abortRun,
|
|
6
|
+
steerAgent,
|
|
7
|
+
followUpAgent,
|
|
8
|
+
getSessionState,
|
|
9
|
+
getSessionEventBus,
|
|
10
|
+
tryAcquireSse,
|
|
11
|
+
releaseSse,
|
|
12
|
+
isSseConnected,
|
|
13
|
+
destroyAgent,
|
|
14
|
+
restoreAgent,
|
|
15
|
+
touchSession,
|
|
16
|
+
listSessions,
|
|
17
|
+
updateSessionModel,
|
|
18
|
+
updateSessionThinkingLevel,
|
|
19
|
+
agentEvents,
|
|
20
|
+
} from '../agent-manager.mjs'
|
|
21
|
+
|
|
22
|
+
export async function handleAgentApi(req, res, url) {
|
|
23
|
+
const pathname = url.pathname
|
|
24
|
+
const parts = pathname.split('/').filter(Boolean)
|
|
25
|
+
|
|
26
|
+
// GET /api/agents — list active sessions
|
|
27
|
+
if (req.method === 'GET' && pathname === '/api/agents') {
|
|
28
|
+
sendJson(res, 200, { sessions: listSessions() })
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// GET /api/agents/events — global SSE event stream for all sessions
|
|
33
|
+
if (req.method === 'GET' && pathname === '/api/agents/events') {
|
|
34
|
+
handleGlobalStream(req, res)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// All other routes need a session ID: /api/agents/:sessionId/...
|
|
39
|
+
if (parts.length < 3 || parts[1] !== 'agents') {
|
|
40
|
+
const error = new Error('Not found')
|
|
41
|
+
error.statusCode = 404
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sessionId = decodeSegment(parts[2])
|
|
46
|
+
if (!sessionId) {
|
|
47
|
+
const error = new Error('Missing session ID')
|
|
48
|
+
error.statusCode = 400
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const subPath = parts.slice(3).join('/')
|
|
53
|
+
|
|
54
|
+
// GET /api/agents/:sessionId/stream — SSE event stream
|
|
55
|
+
// HEAD /api/agents/:sessionId/stream — check if SSE is available (200) or taken (409)
|
|
56
|
+
if (subPath === 'stream') {
|
|
57
|
+
if (req.method === 'GET') {
|
|
58
|
+
await handleStream(req, res, sessionId)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (req.method === 'HEAD') {
|
|
62
|
+
await handleStreamHead(req, res, sessionId)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// POST /api/agents/:sessionId/prompt — send user message
|
|
68
|
+
if (req.method === 'POST' && subPath === 'prompt') {
|
|
69
|
+
const body = await readJsonBody(req)
|
|
70
|
+
const message = body?.message
|
|
71
|
+
if (!message) {
|
|
72
|
+
const error = new Error('Missing message in request body')
|
|
73
|
+
error.statusCode = 400
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
const result = await runPrompt(sessionId, message)
|
|
77
|
+
sendJson(res, 200, result)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// POST /api/agents/:sessionId/abort — abort current run
|
|
82
|
+
if (req.method === 'POST' && subPath === 'abort') {
|
|
83
|
+
const result = abortRun(sessionId)
|
|
84
|
+
sendJson(res, 200, result)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// GET /api/agents/:sessionId/state — get session state
|
|
89
|
+
if (req.method === 'GET' && subPath === 'state') {
|
|
90
|
+
const state = getSessionState(sessionId)
|
|
91
|
+
if (!state) {
|
|
92
|
+
const error = new Error('Session not found')
|
|
93
|
+
error.statusCode = 404
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
sendJson(res, 200, state)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// POST /api/agents/:sessionId — create/ensure agent
|
|
101
|
+
if (req.method === 'POST' && parts.length === 3) {
|
|
102
|
+
const body = await readJsonBody(req)
|
|
103
|
+
const session = await createAgent(sessionId, body)
|
|
104
|
+
sendJson(res, 200, {
|
|
105
|
+
sessionId: session.sessionId,
|
|
106
|
+
status: session.status,
|
|
107
|
+
scope: session.scope,
|
|
108
|
+
title: session.title,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// DELETE /api/agents/:sessionId — destroy agent
|
|
114
|
+
if (req.method === 'DELETE' && parts.length === 3) {
|
|
115
|
+
await destroyAgent(sessionId)
|
|
116
|
+
sendJson(res, 200, { ok: true })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// POST /api/agents/:sessionId/model — update session model
|
|
121
|
+
if (req.method === 'POST' && subPath === 'model') {
|
|
122
|
+
const body = await readJsonBody(req)
|
|
123
|
+
const model = body?.model
|
|
124
|
+
if (!model) {
|
|
125
|
+
const error = new Error('Missing model in request body')
|
|
126
|
+
error.statusCode = 400
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
const result = updateSessionModel(sessionId, model)
|
|
130
|
+
sendJson(res, 200, result)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// POST /api/agents/:sessionId/thinking-level — update session thinking level
|
|
135
|
+
if (req.method === 'POST' && subPath === 'thinking-level') {
|
|
136
|
+
const body = await readJsonBody(req)
|
|
137
|
+
const thinkingLevel = body?.thinkingLevel
|
|
138
|
+
if (!thinkingLevel) {
|
|
139
|
+
const error = new Error('Missing thinkingLevel in request body')
|
|
140
|
+
error.statusCode = 400
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
const result = updateSessionThinkingLevel(sessionId, thinkingLevel)
|
|
144
|
+
sendJson(res, 200, result)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// POST /api/agents/:sessionId/steer — queue steering message
|
|
149
|
+
if (req.method === 'POST' && subPath === 'steer') {
|
|
150
|
+
const body = await readJsonBody(req)
|
|
151
|
+
const message = body?.message
|
|
152
|
+
if (!message) {
|
|
153
|
+
const error = new Error('Missing message in request body')
|
|
154
|
+
error.statusCode = 400
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
const result = steerAgent(sessionId, message)
|
|
158
|
+
sendJson(res, 200, result)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// POST /api/agents/:sessionId/follow-up — queue follow-up message
|
|
163
|
+
if (req.method === 'POST' && subPath === 'follow-up') {
|
|
164
|
+
const body = await readJsonBody(req)
|
|
165
|
+
const message = body?.message
|
|
166
|
+
if (!message) {
|
|
167
|
+
const error = new Error('Missing message in request body')
|
|
168
|
+
error.statusCode = 400
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
171
|
+
const result = followUpAgent(sessionId, message)
|
|
172
|
+
sendJson(res, 200, result)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const error = new Error('Not found')
|
|
177
|
+
error.statusCode = 404
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* HEAD request to check whether the SSE stream for a session is available.
|
|
183
|
+
* Returns 200 if available, 409 if already connected, 404 if session not found.
|
|
184
|
+
*/
|
|
185
|
+
async function handleStreamHead(req, res, sessionId) {
|
|
186
|
+
// Ensure session exists (restore from storage if needed)
|
|
187
|
+
if (!getSessionEventBus(sessionId)) {
|
|
188
|
+
const restored = await restoreAgent(sessionId)
|
|
189
|
+
if (!restored) {
|
|
190
|
+
sendJson(res, 404, { error: 'Session not found' })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (isSseConnected(sessionId)) {
|
|
196
|
+
sendJson(res, 409, { error: 'Session is already active in another tab' })
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
res.writeHead(200, { 'content-length': '0' })
|
|
201
|
+
res.end()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleGlobalStream(req, res) {
|
|
205
|
+
res.writeHead(200, {
|
|
206
|
+
'content-type': 'text/event-stream',
|
|
207
|
+
'cache-control': 'no-cache, no-transform',
|
|
208
|
+
'connection': 'keep-alive',
|
|
209
|
+
'x-accel-buffering': 'no',
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const keepAlive = setInterval(() => {
|
|
213
|
+
try {
|
|
214
|
+
res.write(': ping\n\n')
|
|
215
|
+
} catch {
|
|
216
|
+
cleanup()
|
|
217
|
+
}
|
|
218
|
+
}, 15000)
|
|
219
|
+
|
|
220
|
+
const onAgentEvent = (event) => {
|
|
221
|
+
try {
|
|
222
|
+
writeSseEvent(res, event.type || 'agent_event', event)
|
|
223
|
+
} catch {
|
|
224
|
+
cleanup()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
clearInterval(keepAlive)
|
|
230
|
+
agentEvents.removeListener('agent_event', onAgentEvent)
|
|
231
|
+
if (!res.writableEnded) {
|
|
232
|
+
res.end()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
agentEvents.on('agent_event', onAgentEvent)
|
|
237
|
+
|
|
238
|
+
req.on('close', cleanup)
|
|
239
|
+
req.on('error', cleanup)
|
|
240
|
+
res.on('error', cleanup)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleStream(req, res, sessionId) {
|
|
244
|
+
// Restore from storage if not already in memory
|
|
245
|
+
let eventBus = getSessionEventBus(sessionId)
|
|
246
|
+
if (!eventBus) {
|
|
247
|
+
const restored = await restoreAgent(sessionId)
|
|
248
|
+
if (restored) {
|
|
249
|
+
eventBus = restored.eventBus
|
|
250
|
+
} else {
|
|
251
|
+
sendJson(res, 404, { error: 'Session not found' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Only one SSE connection per session — reject with 409 so the client can fall back
|
|
257
|
+
if (!tryAcquireSse(sessionId)) {
|
|
258
|
+
sendJson(res, 409, { error: 'Session is already active in another tab' })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reset idle timer — active SSE connection keeps session alive
|
|
263
|
+
touchSession(sessionId)
|
|
264
|
+
|
|
265
|
+
// Set SSE headers
|
|
266
|
+
res.writeHead(200, {
|
|
267
|
+
'content-type': 'text/event-stream',
|
|
268
|
+
'cache-control': 'no-cache, no-transform',
|
|
269
|
+
'connection': 'keep-alive',
|
|
270
|
+
'x-accel-buffering': 'no',
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Send initial state
|
|
274
|
+
const state = getSessionState(sessionId)
|
|
275
|
+
if (state) {
|
|
276
|
+
writeSseEvent(res, 'state', state)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Keep-alive ping every 15 seconds — also resets idle timer
|
|
280
|
+
const keepAlive = setInterval(() => {
|
|
281
|
+
try {
|
|
282
|
+
res.write(': ping\n\n')
|
|
283
|
+
touchSession(sessionId)
|
|
284
|
+
} catch {
|
|
285
|
+
cleanup()
|
|
286
|
+
}
|
|
287
|
+
}, 15000)
|
|
288
|
+
|
|
289
|
+
// Handle agent events
|
|
290
|
+
const onAgentEvent = (event) => {
|
|
291
|
+
try {
|
|
292
|
+
writeSseEvent(res, event.type, event)
|
|
293
|
+
} catch {
|
|
294
|
+
cleanup()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const cleanup = () => {
|
|
299
|
+
clearInterval(keepAlive)
|
|
300
|
+
eventBus.removeListener('agent_event', onAgentEvent)
|
|
301
|
+
releaseSse(sessionId)
|
|
302
|
+
if (!res.writableEnded) {
|
|
303
|
+
res.end()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
eventBus.on('agent_event', onAgentEvent)
|
|
308
|
+
|
|
309
|
+
req.on('close', cleanup)
|
|
310
|
+
req.on('error', cleanup)
|
|
311
|
+
res.on('error', cleanup)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function writeSseEvent(res, event, data) {
|
|
315
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data)
|
|
316
|
+
// Split multi-line payloads
|
|
317
|
+
const lines = payload.split('\n')
|
|
318
|
+
res.write(`event: ${event}\n`)
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
res.write(`data: ${line}\n`)
|
|
321
|
+
}
|
|
322
|
+
res.write('\n')
|
|
323
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { sendJson, readJsonBody } from '../utils/response.mjs'
|
|
4
|
+
import {
|
|
5
|
+
ensureStorage,
|
|
6
|
+
readStore,
|
|
7
|
+
writeStore,
|
|
8
|
+
readProjectConfigData,
|
|
9
|
+
writeProjectConfigData,
|
|
10
|
+
storageDir,
|
|
11
|
+
} from '../storage.mjs'
|
|
12
|
+
import { initializeActiveProject } from '../project-config.mjs'
|
|
13
|
+
import { setActiveWorkspaceRootForFilesystem } from './filesystem.mjs'
|
|
14
|
+
import { getWorkspaceRoot } from '../utils/workspace.mjs'
|
|
15
|
+
|
|
16
|
+
const BACKUP_VERSION = 1
|
|
17
|
+
const BACKUP_APP = 'quickforge'
|
|
18
|
+
const backupScopes = new Set(['all', 'config', 'sessions'])
|
|
19
|
+
|
|
20
|
+
function normalizeScope(value) {
|
|
21
|
+
const scope = String(value || 'all')
|
|
22
|
+
return backupScopes.has(scope) ? scope : 'all'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function backupTimestamp(date = new Date()) {
|
|
26
|
+
return date.toISOString().replace(/[:.]/g, '-')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function section(data, key, legacyKey) {
|
|
30
|
+
if (!data || typeof data !== 'object') return undefined
|
|
31
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) return data[key]
|
|
32
|
+
if (legacyKey && Object.prototype.hasOwnProperty.call(data, legacyKey)) return data[legacyKey]
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertObjectSection(value, name) {
|
|
37
|
+
if (value === undefined) return undefined
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
39
|
+
const error = new Error(`Invalid backup section: ${name}`)
|
|
40
|
+
error.statusCode = 400
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertProjectConfig(value) {
|
|
47
|
+
const projectConfig = assertObjectSection(value, 'projects')
|
|
48
|
+
if (projectConfig === undefined) return undefined
|
|
49
|
+
if (!Array.isArray(projectConfig.projects)) {
|
|
50
|
+
const error = new Error('Invalid backup section: projects.projects must be an array')
|
|
51
|
+
error.statusCode = 400
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
activeProjectId: typeof projectConfig.activeProjectId === 'string' ? projectConfig.activeProjectId : null,
|
|
56
|
+
projects: projectConfig.projects,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function filterSessionsByMetadata(sessions, metadata) {
|
|
61
|
+
if (!sessions || !metadata) return sessions
|
|
62
|
+
const metadataIds = new Set(Object.keys(metadata))
|
|
63
|
+
return Object.fromEntries(Object.entries(sessions).filter(([sessionId]) => metadataIds.has(sessionId)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeSessionMetadata(sessions, metadata) {
|
|
67
|
+
if (!sessions) return metadata
|
|
68
|
+
const sessionsObject = assertObjectSection(sessions, 'sessions')
|
|
69
|
+
const metadataObject = metadata === undefined ? {} : assertObjectSection(metadata, 'sessionsMetadata')
|
|
70
|
+
const nextMetadata = {}
|
|
71
|
+
const now = new Date().toISOString()
|
|
72
|
+
|
|
73
|
+
for (const [sessionId, session] of Object.entries(sessionsObject)) {
|
|
74
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) continue
|
|
75
|
+
const existing = metadataObject?.[sessionId]
|
|
76
|
+
nextMetadata[sessionId] = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
77
|
+
? existing
|
|
78
|
+
: {
|
|
79
|
+
id: sessionId,
|
|
80
|
+
title: typeof session.title === 'string' ? session.title : 'New chat',
|
|
81
|
+
createdAt: typeof session.createdAt === 'string' ? session.createdAt : now,
|
|
82
|
+
lastModified: typeof session.lastModified === 'string' ? session.lastModified : now,
|
|
83
|
+
messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
|
|
84
|
+
thinkingLevel: typeof session.thinkingLevel === 'string' ? session.thinkingLevel : 'off',
|
|
85
|
+
preview: '',
|
|
86
|
+
scope: session.scope === 'project' ? 'project' : 'global',
|
|
87
|
+
projectId: session.scope === 'project' && session.projectId ? String(session.projectId) : undefined,
|
|
88
|
+
taskStatus: session.taskStatus || 'idle',
|
|
89
|
+
taskStartedAt: session.taskStartedAt ?? null,
|
|
90
|
+
taskFinishedAt: session.taskFinishedAt ?? null,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return nextMetadata
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function buildBackup(scope = 'all') {
|
|
98
|
+
const normalizedScope = normalizeScope(scope)
|
|
99
|
+
const includeConfig = normalizedScope === 'all' || normalizedScope === 'config'
|
|
100
|
+
const includeSessions = normalizedScope === 'all' || normalizedScope === 'sessions'
|
|
101
|
+
const data = {}
|
|
102
|
+
|
|
103
|
+
if (includeConfig) {
|
|
104
|
+
const [settings, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
|
|
105
|
+
readStore('settings'),
|
|
106
|
+
readStore('provider-keys'),
|
|
107
|
+
readStore('custom-providers'),
|
|
108
|
+
readProjectConfigData(),
|
|
109
|
+
readStore('scheduled-tasks'),
|
|
110
|
+
])
|
|
111
|
+
Object.assign(data, {
|
|
112
|
+
settings,
|
|
113
|
+
providerKeys,
|
|
114
|
+
customProviders,
|
|
115
|
+
projects,
|
|
116
|
+
scheduledTasks,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (includeSessions) {
|
|
121
|
+
const [sessions, sessionsMetadata] = await Promise.all([
|
|
122
|
+
readStore('sessions'),
|
|
123
|
+
readStore('sessions-metadata'),
|
|
124
|
+
])
|
|
125
|
+
Object.assign(data, {
|
|
126
|
+
sessions,
|
|
127
|
+
sessionsMetadata,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
app: BACKUP_APP,
|
|
133
|
+
version: BACKUP_VERSION,
|
|
134
|
+
exportedAt: new Date().toISOString(),
|
|
135
|
+
scope: normalizedScope,
|
|
136
|
+
data,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeBackupPayload(payload) {
|
|
141
|
+
const backup = payload?.backup && typeof payload.backup === 'object' ? payload.backup : payload
|
|
142
|
+
if (!backup || typeof backup !== 'object' || Array.isArray(backup)) {
|
|
143
|
+
const error = new Error('Invalid backup file')
|
|
144
|
+
error.statusCode = 400
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const data = backup.data && typeof backup.data === 'object' && !Array.isArray(backup.data)
|
|
149
|
+
? backup.data
|
|
150
|
+
: backup
|
|
151
|
+
|
|
152
|
+
const normalized = {
|
|
153
|
+
settings: section(data, 'settings'),
|
|
154
|
+
providerKeys: section(data, 'providerKeys', 'provider-keys'),
|
|
155
|
+
customProviders: section(data, 'customProviders', 'custom-providers'),
|
|
156
|
+
projects: section(data, 'projects'),
|
|
157
|
+
scheduledTasks: section(data, 'scheduledTasks', 'scheduled-tasks'),
|
|
158
|
+
sessions: section(data, 'sessions'),
|
|
159
|
+
sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Object.values(normalized).every((value) => value === undefined)) {
|
|
163
|
+
const error = new Error('Backup does not contain any restorable sections')
|
|
164
|
+
error.statusCode = 400
|
|
165
|
+
throw error
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return normalized
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function writeSafetyBackup() {
|
|
172
|
+
const backup = await buildBackup('all')
|
|
173
|
+
const dir = path.join(storageDir, 'backups')
|
|
174
|
+
await fs.mkdir(dir, { recursive: true })
|
|
175
|
+
const file = path.join(dir, `quickforge-before-restore-${backupTimestamp()}.json`)
|
|
176
|
+
await fs.writeFile(file, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
|
|
177
|
+
return file
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function restoreBackup(payload) {
|
|
181
|
+
const backup = normalizeBackupPayload(payload)
|
|
182
|
+
const summary = {}
|
|
183
|
+
|
|
184
|
+
const settings = assertObjectSection(backup.settings, 'settings')
|
|
185
|
+
if (settings !== undefined) {
|
|
186
|
+
await writeStore('settings', settings)
|
|
187
|
+
summary.settings = Object.keys(settings).length
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const providerKeys = assertObjectSection(backup.providerKeys, 'providerKeys')
|
|
191
|
+
if (providerKeys !== undefined) {
|
|
192
|
+
await writeStore('provider-keys', providerKeys)
|
|
193
|
+
summary.providerKeys = Object.keys(providerKeys).length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const customProviders = assertObjectSection(backup.customProviders, 'customProviders')
|
|
197
|
+
if (customProviders !== undefined) {
|
|
198
|
+
await writeStore('custom-providers', customProviders)
|
|
199
|
+
summary.customProviders = Object.keys(customProviders).length
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const projects = assertProjectConfig(backup.projects)
|
|
203
|
+
if (projects !== undefined) {
|
|
204
|
+
await writeProjectConfigData(projects)
|
|
205
|
+
await initializeActiveProject()
|
|
206
|
+
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
207
|
+
summary.projects = projects.projects.length
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const scheduledTasks = assertObjectSection(backup.scheduledTasks, 'scheduledTasks')
|
|
211
|
+
if (scheduledTasks !== undefined) {
|
|
212
|
+
await writeStore('scheduled-tasks', scheduledTasks)
|
|
213
|
+
summary.scheduledTasks = Object.keys(scheduledTasks).length
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sessions = assertObjectSection(backup.sessions, 'sessions')
|
|
217
|
+
const sessionsMetadata = normalizeSessionMetadata(sessions, backup.sessionsMetadata)
|
|
218
|
+
if (sessions !== undefined) {
|
|
219
|
+
await writeStore('sessions', filterSessionsByMetadata(sessions, sessionsMetadata))
|
|
220
|
+
summary.sessions = Object.keys(sessions).length
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (sessionsMetadata !== undefined) {
|
|
224
|
+
await writeStore('sessions-metadata', sessionsMetadata)
|
|
225
|
+
summary.sessionsMetadata = Object.keys(sessionsMetadata).length
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return summary
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function handleBackupApi(req, res, url) {
|
|
232
|
+
if (req.method === 'GET' && url.pathname === '/api/backup/export') {
|
|
233
|
+
await ensureStorage()
|
|
234
|
+
sendJson(res, 200, await buildBackup(url.searchParams.get('scope')))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (req.method === 'POST' && url.pathname === '/api/backup/import') {
|
|
239
|
+
await ensureStorage()
|
|
240
|
+
const body = await readJsonBody(req)
|
|
241
|
+
const safetyBackupPath = await writeSafetyBackup()
|
|
242
|
+
const summary = await restoreBackup(body)
|
|
243
|
+
sendJson(res, 200, { ok: true, safetyBackupPath, summary })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const error = new Error('Not found')
|
|
248
|
+
error.statusCode = 404
|
|
249
|
+
throw error
|
|
250
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
1
|
import { sendJson } from '../utils/response.mjs'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { buildInstructionsPayload } from '../project-config.mjs'
|
|
3
|
+
import { BASE_SYSTEM_PROMPT, composeSystemPrompt } from '../system-prompt.mjs'
|
|
5
4
|
|
|
6
5
|
export async function handleInstructionsApi(req, res, url) {
|
|
7
6
|
if (req.method !== 'GET') {
|
|
@@ -11,21 +10,11 @@ export async function handleInstructionsApi(req, res, url) {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
const projectId = url.searchParams.get('projectId')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (projectId) {
|
|
17
|
-
try {
|
|
18
|
-
const { workspaceRoot } = await projectContextFromId(projectId)
|
|
19
|
-
projectInstructions = await readInstructionsFile(path.join(workspaceRoot, 'AGENTS.md'))
|
|
20
|
-
} catch {
|
|
21
|
-
// project not found or inaccessible — leave projectInstructions null
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
|
|
13
|
+
const instructions = await buildInstructionsPayload(projectId)
|
|
26
14
|
|
|
27
15
|
sendJson(res, 200, {
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
base: BASE_SYSTEM_PROMPT,
|
|
17
|
+
systemPrompt: composeSystemPrompt(instructions),
|
|
18
|
+
...instructions,
|
|
30
19
|
})
|
|
31
20
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
-
import { getActiveProject, setActiveProjectPath, readProjectConfig
|
|
2
|
+
import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
|
|
3
|
+
import { listProjectCommands } from '../custom-commands.mjs'
|
|
4
|
+
import { atomicProjectConfigUpdate } from '../storage.mjs'
|
|
3
5
|
import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.mjs'
|
|
4
|
-
import { selectDirectoryDialog } from '../utils/platform.mjs'
|
|
6
|
+
import { selectDirectoryDialog, openPathInFileManager } from '../utils/platform.mjs'
|
|
5
7
|
import path from 'node:path'
|
|
6
8
|
|
|
7
9
|
export async function handleProjectApi(req, res, url) {
|
|
@@ -12,6 +14,25 @@ export async function handleProjectApi(req, res, url) {
|
|
|
12
14
|
return
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
if (req.method === 'GET' && url.pathname === '/api/project/commands') {
|
|
18
|
+
const projectId = url.searchParams.get('projectId')
|
|
19
|
+
const project = projectId
|
|
20
|
+
? config.projects.find((item) => item.id === projectId)
|
|
21
|
+
: getActiveProject(config)
|
|
22
|
+
const commands = project?.path ? await listProjectCommands(project.path) : []
|
|
23
|
+
sendJson(res, 200, {
|
|
24
|
+
commands: commands.map((command) => ({
|
|
25
|
+
name: command.name,
|
|
26
|
+
description: command.description,
|
|
27
|
+
argumentHint: command.argumentHint,
|
|
28
|
+
allowEdit: command.allowEdit,
|
|
29
|
+
allowCommands: command.allowCommands,
|
|
30
|
+
relativePath: command.relativePath,
|
|
31
|
+
})),
|
|
32
|
+
})
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
if (req.method === 'POST' && url.pathname === '/api/project/select-directory') {
|
|
16
37
|
console.log('[project] Opening directory picker dialog...')
|
|
17
38
|
const selectedPath = await selectDirectoryDialog()
|
|
@@ -45,22 +66,37 @@ export async function handleProjectApi(req, res, url) {
|
|
|
45
66
|
return
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
if (req.method === '
|
|
69
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-explorer')) {
|
|
49
70
|
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
71
|
+
const selected = config.projects.find((project) => project.id === id)
|
|
72
|
+
if (!selected) {
|
|
52
73
|
const error = new Error('Unknown project')
|
|
53
74
|
error.statusCode = 404
|
|
54
75
|
throw error
|
|
55
76
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
await openPathInFileManager(selected.path)
|
|
78
|
+
sendJson(res, 200, { ok: true })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (req.method === 'DELETE' && url.pathname.startsWith('/api/project/')) {
|
|
83
|
+
const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
|
|
84
|
+
const updated = await atomicProjectConfigUpdate((cfg) => {
|
|
85
|
+
const nextProjects = cfg.projects.filter((project) => project.id !== id)
|
|
86
|
+
if (nextProjects.length === cfg.projects.length) {
|
|
87
|
+
const error = new Error('Unknown project')
|
|
88
|
+
error.statusCode = 404
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
cfg.projects = nextProjects
|
|
92
|
+
if (cfg.activeProjectId === id) cfg.activeProjectId = cfg.projects[0]?.id ?? null
|
|
93
|
+
return cfg
|
|
94
|
+
})
|
|
95
|
+
const active = getActiveProject(updated)
|
|
60
96
|
if (active?.path) {
|
|
61
97
|
setWorkspaceRoot(path.resolve(active.path))
|
|
62
98
|
}
|
|
63
|
-
sendJson(res, 200, { project: active ?? null, projects:
|
|
99
|
+
sendJson(res, 200, { project: active ?? null, projects: updated.projects, workspaceRoot: getWorkspaceRoot() })
|
|
64
100
|
return
|
|
65
101
|
}
|
|
66
102
|
|