@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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.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-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.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 +14 -13
  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 +322 -32
  27. package/server/project-config.mjs +80 -31
  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 +46 -10
  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 +58 -10
  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 +247 -6
  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 +30 -0
  52. package/server/utils/response.mjs +8 -1
  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-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,468 @@
1
+ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { promisify } from 'node:util'
5
+ import { ensureStorage, storageDir } from './storage.mjs'
6
+
7
+ const scrypt = promisify(scryptCallback)
8
+ const SHARE_ID_PREFIX = 'qfs_'
9
+ const PASSWORD_VERSION = 1
10
+ const SHARE_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
11
+ const MAX_SHARE_TOKENS = 50
12
+ const SHARES_DIR = path.join(storageDir, 'shares')
13
+ const SHARES_FILE = path.join(SHARES_DIR, 'conversation-shares.json')
14
+ const writeQueueName = 'conversation-shares'
15
+ const writeQueues = new Map()
16
+
17
+ function enqueueWrite(queueName, operation) {
18
+ const previous = writeQueues.get(queueName) || Promise.resolve()
19
+ const next = previous
20
+ .catch(() => undefined)
21
+ .then(operation)
22
+ writeQueues.set(queueName, next)
23
+ return next
24
+ }
25
+
26
+ async function ensureShareStore() {
27
+ await ensureStorage()
28
+ await fs.mkdir(SHARES_DIR, { recursive: true })
29
+ try {
30
+ await fs.access(SHARES_FILE)
31
+ } catch {
32
+ await fs.writeFile(SHARES_FILE, '{}\n', 'utf8')
33
+ }
34
+ }
35
+
36
+ async function readShareStoreFile() {
37
+ await ensureShareStore()
38
+ try {
39
+ const raw = await fs.readFile(SHARES_FILE, 'utf8')
40
+ const text = raw.trimStart()
41
+ const parsed = text ? JSON.parse(text) : {}
42
+ return parsed && typeof parsed === 'object' ? parsed : {}
43
+ } catch (error) {
44
+ if (error?.code === 'ENOENT') return {}
45
+ throw error
46
+ }
47
+ }
48
+
49
+ async function writeShareStoreFile(data) {
50
+ await ensureShareStore()
51
+ await fs.writeFile(SHARES_FILE, `${JSON.stringify(data || {}, null, 2)}\n`, 'utf8')
52
+ }
53
+
54
+ function publicShareRecord(record) {
55
+ if (!record) return null
56
+ return {
57
+ id: record.id,
58
+ sessionId: record.sessionId,
59
+ permission: record.permission,
60
+ createdAt: record.createdAt,
61
+ expiresAt: record.expiresAt,
62
+ revokedAt: record.revokedAt,
63
+ updatedAt: record.updatedAt,
64
+ titleSnapshot: record.titleSnapshot,
65
+ scope: record.scope,
66
+ projectId: record.projectId,
67
+ accessCount: record.accessCount || 0,
68
+ lastAccessedAt: record.lastAccessedAt,
69
+ hasPassword: Boolean(record.passwordHash),
70
+ }
71
+ }
72
+
73
+ function assertValidPermission(permission) {
74
+ if (permission !== 'read' && permission !== 'operate') {
75
+ const error = new Error('Invalid share permission')
76
+ error.statusCode = 400
77
+ throw error
78
+ }
79
+ }
80
+
81
+ function assertSafeShareId(shareId) {
82
+ if (!shareId || typeof shareId !== 'string' || !/^qfs_[A-Za-z0-9_-]{16,80}$/.test(shareId)) {
83
+ const error = new Error('Invalid share id')
84
+ error.statusCode = 400
85
+ throw error
86
+ }
87
+ }
88
+
89
+ function randomToken(bytes = 24) {
90
+ return randomBytes(bytes).toString('base64url')
91
+ }
92
+
93
+ function generateShareId() {
94
+ return `${SHARE_ID_PREFIX}${randomToken(18)}`
95
+ }
96
+
97
+ export function generateSharePassword() {
98
+ return `${randomToken(4).slice(0, 6).toUpperCase()}-${randomToken(4).slice(0, 6).toUpperCase()}`
99
+ }
100
+
101
+ export async function hashSharePassword(password, salt = randomToken(16)) {
102
+ if (password === undefined || password === null || typeof password !== 'string') return {}
103
+ if (!password) return { passwordHash: undefined, passwordSalt: undefined, passwordVersion: undefined }
104
+
105
+ const derived = await scrypt(password, salt, 32)
106
+ return {
107
+ passwordHash: derived.toString('base64url'),
108
+ passwordSalt: salt,
109
+ passwordVersion: PASSWORD_VERSION,
110
+ }
111
+ }
112
+
113
+ export async function verifySharePassword(record, password) {
114
+ if (!record?.passwordHash || !record?.passwordSalt) return !password
115
+ if (!password) return false
116
+ const { passwordHash } = await hashSharePassword(password, record.passwordSalt)
117
+ const expected = Buffer.from(record.passwordHash, 'base64url')
118
+ const actual = Buffer.from(passwordHash, 'base64url')
119
+ if (expected.length !== actual.length) return false
120
+ return timingSafeEqual(expected, actual)
121
+ }
122
+
123
+ export function createShareToken(shareId) {
124
+ assertSafeShareId(shareId)
125
+ const secret = randomToken(32)
126
+ const secretHash = createHash('sha256').update(secret).digest('base64url')
127
+ return {
128
+ token: `${shareId}.${secret}`,
129
+ tokenHash: secretHash,
130
+ }
131
+ }
132
+
133
+ function pruneShareTokens(tokens, now = Date.now()) {
134
+ return (Array.isArray(tokens) ? tokens : [])
135
+ .filter((tokenRecord) => {
136
+ if (!tokenRecord?.tokenHash) return false
137
+ if (!tokenRecord.expiresAt) return true
138
+ return Date.parse(tokenRecord.expiresAt) > now
139
+ })
140
+ .slice(-MAX_SHARE_TOKENS)
141
+ }
142
+
143
+ function safeHashEqual(expectedHash, actualHash) {
144
+ if (!expectedHash || !actualHash) return false
145
+ const expected = Buffer.from(expectedHash)
146
+ const actual = Buffer.from(actualHash)
147
+ if (expected.length !== actual.length) return false
148
+ return timingSafeEqual(expected, actual)
149
+ }
150
+
151
+ export function verifyShareToken(record, token) {
152
+ if (!record || !token || typeof token !== 'string') return false
153
+ const [tokenShareId, secret] = token.split('.')
154
+ if (tokenShareId !== record.id || !secret) return false
155
+ const actualHash = createHash('sha256').update(secret).digest('base64url')
156
+ const authVersion = record.authVersion || 1
157
+ const tokenRecords = pruneShareTokens(record.tokens)
158
+
159
+ if (record.tokenHash) {
160
+ tokenRecords.push({ tokenHash: record.tokenHash, authVersion: record.authVersion || 1 })
161
+ }
162
+
163
+ return tokenRecords.some((tokenRecord) => {
164
+ if ((tokenRecord.authVersion || 1) !== authVersion) return false
165
+ return safeHashEqual(tokenRecord.tokenHash, actualHash)
166
+ })
167
+ }
168
+
169
+ export function parseCookies(cookieHeader) {
170
+ const cookies = new Map()
171
+ for (const part of String(cookieHeader || '').split(';')) {
172
+ const index = part.indexOf('=')
173
+ if (index < 0) continue
174
+ const name = part.slice(0, index).trim()
175
+ const value = part.slice(index + 1).trim()
176
+ if (!name) continue
177
+ cookies.set(name, decodeURIComponent(value))
178
+ }
179
+ return cookies
180
+ }
181
+
182
+ export function shareCookieName(shareId) {
183
+ return `qf_share_${shareId}`
184
+ }
185
+
186
+ export function assertShareActive(record) {
187
+ if (!record) {
188
+ const error = new Error('Share not found')
189
+ error.statusCode = 404
190
+ throw error
191
+ }
192
+ if (record.supersededAt) {
193
+ const error = new Error('Share has been replaced by the current link for this conversation')
194
+ error.statusCode = 410
195
+ throw error
196
+ }
197
+ if (record.revokedAt) {
198
+ const error = new Error('Share has been revoked')
199
+ error.statusCode = 410
200
+ throw error
201
+ }
202
+ if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) {
203
+ const error = new Error('Share has expired')
204
+ error.statusCode = 410
205
+ throw error
206
+ }
207
+ }
208
+
209
+ function currentRecordForSession(data, sessionId) {
210
+ return Object.values(data)
211
+ .filter((record) => record?.sessionId === sessionId && !record.supersededAt)
212
+ .sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)))[0]
213
+ }
214
+
215
+ export async function createConversationShare({
216
+ sessionId,
217
+ permission,
218
+ password,
219
+ expiresAt,
220
+ titleSnapshot,
221
+ scope,
222
+ projectId,
223
+ createdFromHost,
224
+ }) {
225
+ if (!sessionId || typeof sessionId !== 'string') {
226
+ const error = new Error('Missing session id')
227
+ error.statusCode = 400
228
+ throw error
229
+ }
230
+ assertValidPermission(permission)
231
+ if (expiresAt && Number.isNaN(Date.parse(expiresAt))) {
232
+ const error = new Error('Invalid expiration time')
233
+ error.statusCode = 400
234
+ throw error
235
+ }
236
+
237
+ const passwordProvided = typeof password === 'string'
238
+ const normalizedPassword = passwordProvided ? password.trim() : undefined
239
+ const passwordInfo = passwordProvided ? await hashSharePassword(normalizedPassword) : {}
240
+ return enqueueWrite(writeQueueName, async () => {
241
+ const data = await readShareStoreFile()
242
+ const now = new Date().toISOString()
243
+ const existingRecords = Object.values(data)
244
+ .filter((record) => record?.sessionId === sessionId)
245
+ .sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)))
246
+ const existing = currentRecordForSession(data, sessionId)
247
+
248
+ if (permission === 'operate') {
249
+ const willHavePassword = passwordProvided ? Boolean(normalizedPassword) : Boolean(existing?.passwordHash)
250
+ if (!willHavePassword) {
251
+ const error = new Error('Editable shares require a non-empty password')
252
+ error.statusCode = 400
253
+ throw error
254
+ }
255
+ }
256
+
257
+ for (const stale of existingRecords.filter((record) => record?.id !== existing?.id)) {
258
+ stale.supersededAt = stale.supersededAt || now
259
+ stale.revokedAt = stale.revokedAt || now
260
+ stale.updatedAt = now
261
+ stale.tokens = []
262
+ stale.tokenHash = undefined
263
+ stale.tokenIssuedAt = undefined
264
+ stale.tokenExpiresAt = undefined
265
+ data[stale.id] = stale
266
+ }
267
+
268
+ if (existing?.id) {
269
+ const record = {
270
+ ...existing,
271
+ permission,
272
+ ...passwordInfo,
273
+ updatedAt: now,
274
+ supersededAt: undefined,
275
+ expiresAt: expiresAt || undefined,
276
+ revokedAt: undefined,
277
+ titleSnapshot: titleSnapshot || existing.titleSnapshot || 'New chat',
278
+ scope: scope === 'project' ? 'project' : 'global',
279
+ projectId: scope === 'project' ? projectId : undefined,
280
+ createdFromHost: existing.createdFromHost || createdFromHost,
281
+ lastUpdatedFromHost: createdFromHost,
282
+ authVersion: existing.authVersion || 1,
283
+ tokens: existing.tokens,
284
+ tokenHash: existing.tokenHash,
285
+ tokenIssuedAt: existing.tokenIssuedAt,
286
+ tokenExpiresAt: existing.tokenExpiresAt,
287
+ }
288
+ if (passwordProvided) {
289
+ record.authVersion = (existing.authVersion || 1) + 1
290
+ record.tokens = []
291
+ record.tokenHash = undefined
292
+ record.tokenIssuedAt = undefined
293
+ record.tokenExpiresAt = undefined
294
+ }
295
+ if (passwordProvided && !passwordInfo.passwordHash) {
296
+ record.passwordHash = undefined
297
+ record.passwordSalt = undefined
298
+ record.passwordVersion = undefined
299
+ }
300
+ data[record.id] = record
301
+ await writeShareStoreFile(data)
302
+ return publicShareRecord(record)
303
+ }
304
+
305
+ let id = generateShareId()
306
+ while (data[id]) id = generateShareId()
307
+ const record = {
308
+ id,
309
+ sessionId,
310
+ permission,
311
+ ...passwordInfo,
312
+ authVersion: 1,
313
+ createdAt: now,
314
+ updatedAt: now,
315
+ supersededAt: undefined,
316
+ expiresAt: expiresAt || undefined,
317
+ revokedAt: undefined,
318
+ titleSnapshot: titleSnapshot || 'New chat',
319
+ scope: scope === 'project' ? 'project' : 'global',
320
+ projectId: scope === 'project' ? projectId : undefined,
321
+ accessCount: 0,
322
+ lastAccessedAt: undefined,
323
+ createdFromHost,
324
+ tokens: [],
325
+ tokenHash: undefined,
326
+ tokenIssuedAt: undefined,
327
+ tokenExpiresAt: undefined,
328
+ }
329
+ data[id] = record
330
+ await writeShareStoreFile(data)
331
+ return publicShareRecord(record)
332
+ })
333
+ }
334
+
335
+ export async function readConversationShare(shareId) {
336
+ assertSafeShareId(shareId)
337
+ const data = await readShareStoreFile()
338
+ return data[shareId] || null
339
+ }
340
+
341
+ export async function listConversationShares(sessionId) {
342
+ const data = await readShareStoreFile()
343
+ return Object.values(data)
344
+ .filter((record) => !sessionId || record.sessionId === sessionId)
345
+ .filter((record) => !record.supersededAt)
346
+ .map(publicShareRecord)
347
+ .sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)))
348
+ }
349
+
350
+ export async function revokeConversationShare(shareId) {
351
+ assertSafeShareId(shareId)
352
+ return enqueueWrite(writeQueueName, async () => {
353
+ const data = await readShareStoreFile()
354
+ const record = data[shareId]
355
+ if (!record) {
356
+ const error = new Error('Share not found')
357
+ error.statusCode = 404
358
+ throw error
359
+ }
360
+ record.revokedAt = record.revokedAt || new Date().toISOString()
361
+ record.updatedAt = record.revokedAt
362
+ record.tokens = []
363
+ record.tokenHash = undefined
364
+ record.tokenIssuedAt = undefined
365
+ record.tokenExpiresAt = undefined
366
+ data[shareId] = record
367
+ await writeShareStoreFile(data)
368
+ return publicShareRecord(record)
369
+ })
370
+ }
371
+
372
+ export async function issueConversationShareToken(shareId) {
373
+ assertSafeShareId(shareId)
374
+ return enqueueWrite(writeQueueName, async () => {
375
+ const data = await readShareStoreFile()
376
+ const record = data[shareId]
377
+ assertShareActive(record)
378
+ const { token, tokenHash } = createShareToken(shareId)
379
+ const issuedAt = new Date().toISOString()
380
+ const expiresAt = new Date(Date.now() + SHARE_TOKEN_MAX_AGE_MS).toISOString()
381
+ record.tokens = pruneShareTokens(record.tokens)
382
+ record.tokens.push({ tokenHash, issuedAt, expiresAt, authVersion: record.authVersion || 1 })
383
+ record.tokens = record.tokens.slice(-MAX_SHARE_TOKENS)
384
+ record.tokenHash = undefined
385
+ record.tokenIssuedAt = undefined
386
+ record.tokenExpiresAt = undefined
387
+ record.accessCount = (record.accessCount || 0) + 1
388
+ record.lastAccessedAt = issuedAt
389
+ data[shareId] = record
390
+ await writeShareStoreFile(data)
391
+ return { token, share: publicShareRecord(record) }
392
+ })
393
+ }
394
+
395
+ export async function rollbackSharedSessionMessages(record, rollbackMessageIndex) {
396
+ const { readSessionValue, writeSessionValue, atomicUpdate } = await import('./storage.mjs')
397
+ const { replaceSessionMessages } = await import('./agent-manager.mjs')
398
+ const session = await readSessionValue(record.sessionId)
399
+ if (!session) {
400
+ const error = new Error('Session not found')
401
+ error.statusCode = 404
402
+ throw error
403
+ }
404
+ const messages = Array.isArray(session.messages) ? session.messages : []
405
+ const rollbackIndex = rollbackStartIndexFromMessage(messages, rollbackMessageIndex)
406
+ if (rollbackIndex < 0) {
407
+ const error = new Error('There is no conversation turn to roll back.')
408
+ error.statusCode = 400
409
+ throw error
410
+ }
411
+ const nextMessages = messages.slice(0, rollbackIndex)
412
+ const activeState = await replaceSessionMessages(record.sessionId, nextMessages)
413
+ if (activeState) return { session: activeState, rollbackIndex }
414
+
415
+ const now = new Date().toISOString()
416
+ await writeSessionValue(record.sessionId, {
417
+ ...session,
418
+ messages: nextMessages,
419
+ lastModified: now,
420
+ })
421
+ await atomicUpdate('sessions-metadata', (metadata) => {
422
+ const existing = metadata[record.sessionId]
423
+ if (existing) {
424
+ metadata[record.sessionId] = {
425
+ ...existing,
426
+ messageCount: nextMessages.length,
427
+ lastModified: now,
428
+ preview: previewFromMessages(nextMessages),
429
+ }
430
+ }
431
+ return metadata
432
+ })
433
+ return { session: { ...session, messages: nextMessages, lastModified: now }, rollbackIndex }
434
+ }
435
+
436
+ function rollbackStartIndexFromMessage(messages, messageIndex) {
437
+ let rollbackIndex = Number(messageIndex)
438
+ if (!Number.isInteger(rollbackIndex) || rollbackIndex < 0 || rollbackIndex >= messages.length) return -1
439
+
440
+ if (messages[rollbackIndex]?.role === 'assistant') {
441
+ for (let index = rollbackIndex - 1; index >= 0; index--) {
442
+ if (messages[index].role === 'user' || messages[index].role === 'user-with-attachments') {
443
+ rollbackIndex = index
444
+ break
445
+ }
446
+ }
447
+ }
448
+
449
+ const message = messages[rollbackIndex]
450
+ if (!message || (message.role !== 'user' && message.role !== 'user-with-attachments')) return -1
451
+ return rollbackIndex
452
+ }
453
+
454
+ function textFromContent(content) {
455
+ if (typeof content === 'string') return content
456
+ if (!Array.isArray(content)) return ''
457
+ return content
458
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
459
+ .map((block) => block.text)
460
+ .join(' ')
461
+ }
462
+
463
+ function previewFromMessages(messages) {
464
+ for (let i = messages.length - 1; i >= 0; i--) {
465
+ if (messages[i]?.role === 'assistant') return textFromContent(messages[i].content).slice(0, 200)
466
+ }
467
+ return ''
468
+ }