| 1 | import {lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile} from 'node:fs/promises'; |
| 2 | import path from 'node:path'; |
| 3 | |
| 4 | const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']); |
| 5 | const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov']); |
| 6 | const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json']); |
| 7 | |
| 8 | export async function readSessionState(repoRoot) { |
| 9 | const fallback = {activeSessionId: '', sessions: []}; |
| 10 | try { |
| 11 | const payload = JSON.parse(await readFile(path.join(repoRoot, '.vimax', 'sessions.json'), 'utf8')); |
| 12 | const records = Object.values(payload.sessions ?? {}) |
| 13 | .filter((record) => record && typeof record === 'object') |
| 14 | .map(sanitizeSession); |
| 15 | records.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); |
| 16 | return { |
| 17 | activeSessionId: String(payload.active_session_id ?? ''), |
| 18 | sessions: records, |
| 19 | }; |
| 20 | } catch { |
| 21 | return fallback; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | export async function deleteSession(repoRoot, sessionId) { |
| 26 | assertSessionId(sessionId); |
| 27 | const statePath = path.join(repoRoot, '.vimax', 'sessions.json'); |
| 28 | const payload = JSON.parse(await readFile(statePath, 'utf8')); |
| 29 | const sessions = payload.sessions && typeof payload.sessions === 'object' ? payload.sessions : {}; |
| 30 | if (!sessions[sessionId]) throw new Error('Project not found'); |
| 31 | |
| 32 | delete sessions[sessionId]; |
| 33 | const remaining = Object.values(sessions) |
| 34 | .filter((record) => record && typeof record === 'object') |
| 35 | .sort((left, right) => String(right.updated_at ?? right.created_at ?? '').localeCompare(String(left.updated_at ?? left.created_at ?? ''))); |
| 36 | if (payload.active_session_id === sessionId || !sessions[payload.active_session_id]) { |
| 37 | payload.active_session_id = String(remaining[0]?.session_id ?? ''); |
| 38 | } |
| 39 | payload.sessions = sessions; |
| 40 | |
| 41 | const temporaryPath = `${statePath}.${process.pid}.tmp`; |
| 42 | await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {mode: 0o600}); |
| 43 | await rename(temporaryPath, statePath); |
| 44 | await rm(resolveSessionRoot(repoRoot, sessionId), {recursive: true, force: true}); |
| 45 | await removeSessionLogRecords(repoRoot, sessionId); |
| 46 | return readSessionState(repoRoot); |
| 47 | } |
| 48 | |
| 49 | export async function readSessionHistory(repoRoot, sessionId) { |
| 50 | assertSessionId(sessionId); |
| 51 | const logPath = path.join(repoRoot, '.vimax', 'logs', 'loop_history.jsonl'); |
| 52 | try { |
| 53 | const lines = (await readFile(logPath, 'utf8')).split(/\r?\n/).filter(Boolean); |
| 54 | const messages = []; |
| 55 | for (const line of lines) { |
| 56 | let record; |
| 57 | try { |
| 58 | record = JSON.parse(line); |
| 59 | } catch { |
| 60 | continue; |
| 61 | } |
| 62 | if (record.session_id !== sessionId || !record.raw_user_input) continue; |
| 63 | const turnId = String(record.turn_id || `turn-${messages.length}`); |
| 64 | messages.push({ |
| 65 | id: `${turnId}-user`, |
| 66 | role: 'user', |
| 67 | text: displayUserInput(record.raw_user_input), |
| 68 | createdAt: String(record.created_at || record.timestamp || ''), |
| 69 | }); |
| 70 | for (const round of Array.isArray(record.tool_rounds) ? record.tool_rounds : []) { |
| 71 | for (const result of Array.isArray(round.tool_results) ? round.tool_results : []) { |
| 72 | messages.push({ |
| 73 | id: `${turnId}-tool-${messages.length}`, |
| 74 | role: 'activity', |
| 75 | text: historyToolResultText(result), |
| 76 | tool: String(result.name || 'tool'), |
| 77 | status: result.ok === false ? 'error' : 'done', |
| 78 | stage: result.ok === false ? 'failed' : 'completed', |
| 79 | createdAt: String(record.created_at || record.timestamp || ''), |
| 80 | }); |
| 81 | } |
| 82 | } |
| 83 | if (record.final_assistant_text) { |
| 84 | messages.push({ |
| 85 | id: `${turnId}-assistant`, |
| 86 | role: record.status === 'failed' ? 'error' : 'assistant', |
| 87 | text: String(record.final_assistant_text), |
| 88 | createdAt: String(record.created_at || record.timestamp || ''), |
| 89 | }); |
| 90 | } |
| 91 | } |
| 92 | return messages.slice(-120); |
| 93 | } catch { |
| 94 | return []; |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | export async function storeWorkspaceUpload(repoRoot, sessionId, fileName, data) { |
| 99 | const sessionRoot = resolveSessionRoot(repoRoot, sessionId); |
| 100 | const sessionInfo = await stat(sessionRoot); |
| 101 | if (!sessionInfo.isDirectory()) throw new Error('Session workspace is not a directory'); |
| 102 | |
| 103 | const safeName = validateUploadName(fileName); |
| 104 | const uploadRoot = path.join(sessionRoot, 'uploads'); |
| 105 | await mkdir(uploadRoot, {recursive: true, mode: 0o700}); |
| 106 | const uploadRootInfo = await lstat(uploadRoot); |
| 107 | if (!uploadRootInfo.isDirectory() || uploadRootInfo.isSymbolicLink()) { |
| 108 | throw new Error('Workspace upload directory is not safe'); |
| 109 | } |
| 110 | |
| 111 | const extension = path.extname(safeName); |
| 112 | const stem = safeName.slice(0, safeName.length - extension.length); |
| 113 | const payload = Buffer.isBuffer(data) ? data : Buffer.from(data); |
| 114 | for (let attempt = 1; attempt <= 1_000; attempt += 1) { |
| 115 | const storedName = attempt === 1 ? safeName : `${stem} (${attempt})${extension}`; |
| 116 | const destination = path.join(uploadRoot, storedName); |
| 117 | try { |
| 118 | await writeFile(destination, payload, {flag: 'wx', mode: 0o600}); |
| 119 | return { |
| 120 | name: storedName, |
| 121 | path: path.relative(sessionRoot, destination).split(path.sep).join('/'), |
| 122 | size: payload.byteLength, |
| 123 | }; |
| 124 | } catch (error) { |
| 125 | if (error?.code === 'EEXIST') continue; |
| 126 | throw error; |
| 127 | } |
| 128 | } |
| 129 | throw new Error('Could not allocate a unique upload filename'); |
| 130 | } |
| 131 | |
| 132 | function historyToolResultText(result) { |
| 133 | if (result.ok !== false) return 'Completed'; |
| 134 | const content = String(result.content || 'Tool failed').trim(); |
| 135 | try { |
| 136 | const payload = JSON.parse(content); |
| 137 | const detail = payload?.error ?? payload?.message; |
| 138 | if (detail) return conciseText(detail); |
| 139 | } catch { |
| 140 | // The provider may return a plain-text error. |
| 141 | } |
| 142 | return conciseText(content); |
| 143 | } |
| 144 | |
| 145 | function conciseText(value) { |
| 146 | const text = String(value || 'Tool failed').replace(/\s+/g, ' ').trim(); |
| 147 | return text.length > 280 ? `${text.slice(0, 277)}…` : text; |
| 148 | } |
| 149 | |
| 150 | function displayUserInput(value) { |
| 151 | return String(value || '') |
| 152 | .replace(/\s*<workspace_uploads>.*<\/workspace_uploads>\s*$/s, '') |
| 153 | .trim(); |
| 154 | } |
| 155 | |
| 156 | export async function listSessionArtifacts(repoRoot, sessionId) { |
| 157 | const sessionRoot = resolveSessionRoot(repoRoot, sessionId); |
| 158 | const artifacts = []; |
| 159 | |
| 160 | async function walk(directory) { |
| 161 | let entries; |
| 162 | try { |
| 163 | entries = await readdir(directory, {withFileTypes: true}); |
| 164 | } catch { |
| 165 | return; |
| 166 | } |
| 167 | for (const entry of entries) { |
| 168 | if (artifacts.length >= 400 || entry.name.startsWith('.')) continue; |
| 169 | const absolute = path.join(directory, entry.name); |
| 170 | if (entry.isDirectory()) { |
| 171 | await walk(absolute); |
| 172 | continue; |
| 173 | } |
| 174 | if (!entry.isFile()) continue; |
| 175 | const extension = path.extname(entry.name).toLowerCase(); |
| 176 | const kind = IMAGE_EXTENSIONS.has(extension) |
| 177 | ? 'image' |
| 178 | : VIDEO_EXTENSIONS.has(extension) |
| 179 | ? 'video' |
| 180 | : TEXT_EXTENSIONS.has(extension) |
| 181 | ? 'document' |
| 182 | : null; |
| 183 | if (!kind) continue; |
| 184 | const info = await stat(absolute); |
| 185 | const relativePath = path.relative(sessionRoot, absolute).split(path.sep).join('/'); |
| 186 | artifacts.push({ |
| 187 | path: relativePath, |
| 188 | name: entry.name, |
| 189 | kind, |
| 190 | size: info.size, |
| 191 | updatedAt: info.mtime.toISOString(), |
| 192 | url: `/api/artifact?session=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(relativePath)}`, |
| 193 | }); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | await walk(sessionRoot); |
| 198 | return artifacts.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); |
| 199 | } |
| 200 | |
| 201 | export function resolveArtifactPath(repoRoot, sessionId, relativePath) { |
| 202 | const sessionRoot = resolveSessionRoot(repoRoot, sessionId); |
| 203 | const candidate = path.resolve(sessionRoot, String(relativePath || '')); |
| 204 | if (candidate === sessionRoot || !candidate.startsWith(`${sessionRoot}${path.sep}`)) { |
| 205 | throw new Error('Artifact path escapes the active session'); |
| 206 | } |
| 207 | return candidate; |
| 208 | } |
| 209 | |
| 210 | export function artifactContentType(filePath) { |
| 211 | const extension = path.extname(filePath).toLowerCase(); |
| 212 | return { |
| 213 | '.html': 'text/html; charset=utf-8', |
| 214 | '.js': 'text/javascript; charset=utf-8', |
| 215 | '.css': 'text/css; charset=utf-8', |
| 216 | '.svg': 'image/svg+xml', |
| 217 | '.woff': 'font/woff', |
| 218 | '.woff2': 'font/woff2', |
| 219 | '.png': 'image/png', |
| 220 | '.jpg': 'image/jpeg', |
| 221 | '.jpeg': 'image/jpeg', |
| 222 | '.webp': 'image/webp', |
| 223 | '.gif': 'image/gif', |
| 224 | '.mp4': 'video/mp4', |
| 225 | '.webm': 'video/webm', |
| 226 | '.mov': 'video/quicktime', |
| 227 | '.json': 'application/json; charset=utf-8', |
| 228 | '.txt': 'text/plain; charset=utf-8', |
| 229 | '.md': 'text/markdown; charset=utf-8', |
| 230 | }[extension] ?? 'application/octet-stream'; |
| 231 | } |
| 232 | |
| 233 | function resolveSessionRoot(repoRoot, sessionId) { |
| 234 | assertSessionId(sessionId); |
| 235 | const workingRoot = path.resolve(repoRoot, '.working_dir'); |
| 236 | const candidate = path.resolve(workingRoot, sessionId); |
| 237 | if (!candidate.startsWith(`${workingRoot}${path.sep}`)) { |
| 238 | throw new Error('Session path escapes .working_dir'); |
| 239 | } |
| 240 | return candidate; |
| 241 | } |
| 242 | |
| 243 | function assertSessionId(sessionId) { |
| 244 | if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,95}$/.test(String(sessionId || ''))) { |
| 245 | throw new Error('Invalid session id'); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | function validateUploadName(fileName) { |
| 250 | const value = String(fileName || '').normalize('NFC').trim(); |
| 251 | if (!value || value === '.' || value === '..') throw new Error('A valid filename is required'); |
| 252 | if (value.length > 180) throw new Error('Filename must be 180 characters or fewer'); |
| 253 | if (/[\\/\u0000-\u001f\u007f]/.test(value)) throw new Error('Filename contains unsupported characters'); |
| 254 | return value; |
| 255 | } |
| 256 | |
| 257 | async function removeSessionLogRecords(repoRoot, sessionId) { |
| 258 | const logsRoot = path.join(repoRoot, '.vimax', 'logs'); |
| 259 | let entries; |
| 260 | try { |
| 261 | entries = await readdir(logsRoot, {withFileTypes: true}); |
| 262 | } catch { |
| 263 | return; |
| 264 | } |
| 265 | for (const entry of entries) { |
| 266 | if (!entry.isFile() || path.extname(entry.name) !== '.jsonl') continue; |
| 267 | const logPath = path.join(logsRoot, entry.name); |
| 268 | const lines = (await readFile(logPath, 'utf8')).split(/\r?\n/).filter(Boolean); |
| 269 | const retained = lines.filter((line) => { |
| 270 | try { |
| 271 | const record = JSON.parse(line); |
| 272 | const recordSessionId = record.session_id |
| 273 | ?? record.sessionId |
| 274 | ?? record.session?.session_id |
| 275 | ?? record.context?.session_id |
| 276 | ?? record.metadata?.session_id; |
| 277 | return recordSessionId !== sessionId; |
| 278 | } catch { |
| 279 | return true; |
| 280 | } |
| 281 | }); |
| 282 | if (retained.length === lines.length) continue; |
| 283 | const temporaryPath = `${logPath}.${process.pid}.tmp`; |
| 284 | await writeFile(temporaryPath, retained.length ? `${retained.join('\n')}\n` : '', {mode: 0o600}); |
| 285 | await rename(temporaryPath, logPath); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | function sanitizeSession(record) { |
| 290 | return { |
| 291 | sessionId: String(record.session_id ?? ''), |
| 292 | projectName: String(record.project_name ?? ''), |
| 293 | workingDir: String(record.working_dir ?? ''), |
| 294 | stage: String(record.stage ?? 'created'), |
| 295 | summary: String(record.summary ?? ''), |
| 296 | idea: String(record.idea ?? ''), |
| 297 | updatedAt: String(record.updated_at ?? record.created_at ?? ''), |
| 298 | createdAt: String(record.created_at ?? ''), |
| 299 | compactionTurns: Number(record.compacted_turns ?? 0), |
| 300 | }; |
| 301 | } |
| 302 |