@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,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
|
+
}
|