Spaces:
Running
Running
| import fs from 'node:fs/promises'; | |
| import fsSync from 'node:fs'; | |
| import path from 'node:path'; | |
| import readline from 'node:readline'; | |
| import { CODEX_SESSION_INDEX } from './codex-config.js'; | |
| import { displayNameFor, normalizeComparablePath, projectIdFor } from './codex-data-projects.js'; | |
| export { normalizeComparablePath, projectIdFor, toPublicProject } from './codex-data-projects.js'; | |
| export async function walkJsonlFiles(dir) { | |
| const files = []; | |
| async function walk(current) { | |
| let entries; | |
| try { | |
| entries = await fs.readdir(current, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const entry of entries) { | |
| const fullPath = path.join(current, entry.name); | |
| if (entry.isDirectory()) { | |
| await walk(fullPath); | |
| } else if (entry.isFile() && entry.name.endsWith('.jsonl') && !entry.name.startsWith('agent-')) { | |
| files.push(fullPath); | |
| } | |
| } | |
| } | |
| await walk(dir); | |
| return files; | |
| } | |
| export async function readSessionNameIndex() { | |
| const index = new Map(); | |
| try { | |
| const raw = await fs.readFile(CODEX_SESSION_INDEX, 'utf8'); | |
| for (const line of raw.split(/\r?\n/)) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| try { | |
| const item = JSON.parse(line); | |
| if (item.id && item.thread_name) { | |
| index.set(item.id, { | |
| title: item.thread_name, | |
| updatedAt: item.updated_at || null | |
| }); | |
| } | |
| } catch { | |
| // Skip malformed index rows. | |
| } | |
| } | |
| } catch (error) { | |
| if (error.code !== 'ENOENT') { | |
| console.warn('[sessions] Failed to read session index:', error.message); | |
| } | |
| } | |
| return index; | |
| } | |
| export async function renameSessionNameIndexRow(sessionId, title, updatedAt, options = {}) { | |
| const indexPath = options.indexPath || CODEX_SESSION_INDEX; | |
| const shouldRefreshUpdatedAt = Boolean(options.refreshUpdatedAt); | |
| const nextUpdatedAt = updatedAt || new Date().toISOString(); | |
| try { | |
| const raw = await fs.readFile(indexPath, 'utf8'); | |
| const nextLines = []; | |
| let changed = false; | |
| for (const line of raw.split(/\r?\n/)) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| try { | |
| const item = JSON.parse(line); | |
| if (item?.id === sessionId) { | |
| item.thread_name = title; | |
| item.updated_at = shouldRefreshUpdatedAt ? nextUpdatedAt : item.updated_at || nextUpdatedAt; | |
| nextLines.push(JSON.stringify(item)); | |
| changed = true; | |
| continue; | |
| } | |
| } catch { | |
| // Preserve malformed rows. | |
| } | |
| nextLines.push(line); | |
| } | |
| if (!changed) { | |
| nextLines.push(JSON.stringify({ | |
| id: sessionId, | |
| thread_name: title, | |
| updated_at: nextUpdatedAt | |
| })); | |
| } | |
| await fs.writeFile(indexPath, `${nextLines.join('\n')}\n`, 'utf8'); | |
| return true; | |
| } catch (error) { | |
| if (error.code === 'ENOENT') { | |
| await fs.writeFile( | |
| indexPath, | |
| `${JSON.stringify({ | |
| id: sessionId, | |
| thread_name: title, | |
| updated_at: nextUpdatedAt | |
| })}\n`, | |
| 'utf8' | |
| ); | |
| return true; | |
| } | |
| throw error; | |
| } | |
| } | |
| export function isVisibleUserMessage(payload) { | |
| return ( | |
| payload?.type === 'user_message' && | |
| (!payload.kind || payload.kind === 'plain') && | |
| typeof payload.message === 'string' && | |
| sanitizeVisibleUserMessage(payload.message).trim().length > 0 | |
| ); | |
| } | |
| export const INTERNAL_PROMPT_MARKERS = [ | |
| 'CodexMobile iOS/PWA 回复要求:', | |
| 'CodexMobile 已接入飞书官方 lark-cli。', | |
| 'CodexMobile 已接入飞书官方 lark-cli' | |
| ]; | |
| export function sanitizeVisibleUserMessage(message) { | |
| const value = String(message || '').trim(); | |
| if (!value) { | |
| return ''; | |
| } | |
| let cutAt = value.length; | |
| for (const marker of INTERNAL_PROMPT_MARKERS) { | |
| const index = value.indexOf(marker); | |
| if (index > 0) { | |
| cutAt = Math.min(cutAt, index); | |
| } | |
| } | |
| return value.slice(0, cutAt).trim() || value; | |
| } | |
| export function extractContent(content) { | |
| if (typeof content === 'string') { | |
| return content; | |
| } | |
| if (!Array.isArray(content)) { | |
| return ''; | |
| } | |
| return content | |
| .map((part) => { | |
| if (typeof part === 'string') { | |
| return part; | |
| } | |
| if (part?.type === 'output_text' || part?.type === 'input_text' || part?.type === 'text') { | |
| return part.text || ''; | |
| } | |
| return ''; | |
| }) | |
| .filter(Boolean) | |
| .join('\n'); | |
| } | |
| function sessionIdentityFromPayload(payload) { | |
| if (!payload?.id || !payload?.cwd) { | |
| return null; | |
| } | |
| return { | |
| id: payload.id, | |
| cwd: payload.cwd, | |
| projectId: projectIdFor(payload.cwd) | |
| }; | |
| } | |
| function buildSessionMetadata({ | |
| filePath, | |
| meta, | |
| sessionIndex, | |
| mobileSessionIndex, | |
| lastTimestamp, | |
| lastUserMessage, | |
| messageCount | |
| }) { | |
| if (!meta?.id || !meta.cwd) { | |
| return null; | |
| } | |
| const indexedSession = sessionIndex.get(meta.id); | |
| const mobileSession = mobileSessionIndex.get(meta.id); | |
| const indexEntry = indexedSession || mobileSession || {}; | |
| const mobileMessages = Array.isArray(mobileSession?.messages) ? mobileSession.messages : []; | |
| const mobileUpdatedAt = mobileSession?.updatedAt || null; | |
| const updatedAt = | |
| mobileUpdatedAt && (!lastTimestamp || new Date(mobileUpdatedAt) > new Date(lastTimestamp)) | |
| ? mobileUpdatedAt | |
| : lastTimestamp || meta.timestamp; | |
| return { | |
| id: meta.id, | |
| cwd: meta.cwd, | |
| projectId: projectIdFor(meta.cwd), | |
| title: mobileSession?.title || indexEntry.title || (lastUserMessage ? lastUserMessage.slice(0, 52) : '新对话'), | |
| summary: mobileSession?.summary || lastUserMessage || indexEntry.summary || indexEntry.title || 'Codex 会话', | |
| model: meta.model, | |
| provider: meta.provider, | |
| messageCount: messageCount + mobileMessages.length, | |
| updatedAt, | |
| source: 'codex-app', | |
| filePath | |
| }; | |
| } | |
| async function readSessionMetadata(options) { | |
| const { filePath, sessionIndex, mobileSessionIndex, includeIdentity } = options; | |
| const stream = fsSync.createReadStream(filePath, { encoding: 'utf8' }); | |
| const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); | |
| let meta = null; | |
| let lastTimestamp = null; | |
| let lastUserMessage = ''; | |
| let messageCount = 0; | |
| for await (const line of rl) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| try { | |
| const entry = JSON.parse(line); | |
| if (entry.timestamp) { | |
| lastTimestamp = entry.timestamp; | |
| } | |
| if (entry.type === 'session_meta' && entry.payload?.id) { | |
| const identity = sessionIdentityFromPayload(entry.payload); | |
| if (identity && includeIdentity && !includeIdentity(identity)) { | |
| rl.close(); | |
| stream.destroy(); | |
| return null; | |
| } | |
| meta = { | |
| id: entry.payload.id, | |
| cwd: entry.payload.cwd, | |
| model: entry.payload.model || null, | |
| provider: entry.payload.model_provider || null, | |
| timestamp: entry.timestamp || entry.payload.timestamp || null | |
| }; | |
| } | |
| if (entry.type === 'event_msg' && isVisibleUserMessage(entry.payload)) { | |
| messageCount += 1; | |
| lastUserMessage = sanitizeVisibleUserMessage(entry.payload.message); | |
| } | |
| if ( | |
| entry.type === 'response_item' && | |
| entry.payload?.type === 'message' && | |
| entry.payload.role === 'assistant' && | |
| entry.payload.phase !== 'commentary' | |
| ) { | |
| messageCount += 1; | |
| } | |
| } catch { | |
| // Skip malformed or partial rows. | |
| } | |
| } | |
| return buildSessionMetadata({ | |
| filePath, | |
| meta, | |
| sessionIndex, | |
| mobileSessionIndex, | |
| lastTimestamp, | |
| lastUserMessage, | |
| messageCount | |
| }); | |
| } | |
| export async function parseSessionMetadata(filePath, sessionIndex, mobileSessionIndex) { | |
| return readSessionMetadata({ filePath, sessionIndex, mobileSessionIndex }); | |
| } | |
| export async function parseFilteredSessionMetadata(options) { | |
| return readSessionMetadata(options); | |
| } | |
| export async function parseSessionIdentity(filePath) { | |
| const stream = fsSync.createReadStream(filePath, { encoding: 'utf8' }); | |
| const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); | |
| try { | |
| for await (const line of rl) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| try { | |
| const entry = JSON.parse(line); | |
| const identity = entry.type === 'session_meta' ? sessionIdentityFromPayload(entry.payload) : null; | |
| if (identity) { | |
| rl.close(); | |
| stream.destroy(); | |
| return identity; | |
| } | |
| } catch { | |
| // Skip malformed or partial rows. | |
| } | |
| } | |
| } finally { | |
| rl.close(); | |
| stream.destroy(); | |
| } | |
| return null; | |
| } | |
| export function upsertProject(projectMap, projectPath, trustLevel = null, label = null) { | |
| const normalized = normalizeComparablePath(projectPath); | |
| if (!normalized) { | |
| return null; | |
| } | |
| const id = projectIdFor(projectPath); | |
| const existing = projectMap.get(id); | |
| if (existing) { | |
| if (trustLevel) { | |
| existing.trusted = trustLevel === 'trusted'; | |
| } | |
| if (label) { | |
| existing.name = label; | |
| } | |
| return existing; | |
| } | |
| const entry = { | |
| id, | |
| name: label || displayNameFor(projectPath), | |
| path: path.resolve(projectPath), | |
| trusted: trustLevel === 'trusted', | |
| updatedAt: null, | |
| sessionCount: 0 | |
| }; | |
| projectMap.set(id, entry); | |
| return entry; | |
| } | |