feat: v1.2.8 - Codex双Agent、图片上传、主题系统、会话加载优化
- Codex双Agent接入:共享后端内核,前台隔离会话/设置/导入 - 图片上传:Claude (stream-json) 和 Codex (--image) 均支持拖拽/粘贴/选择上传 - 主题系统:CoolVibe Light 视觉方案,主题入口移至二级页 - 会话加载优化:加载遮罩、热会话缓存、切后台内容不丢失 - 移动端增强:侧栏手势、运行状态标签、按钮比例修复 - 后端重构:agent-runtime.js / codex-rollouts.js 模块拆分 - 回归脚本:npm run regression 隔离式测试
This commit is contained in:
390
lib/agent-runtime.js
Normal file
390
lib/agent-runtime.js
Normal file
@@ -0,0 +1,390 @@
|
||||
function createAgentRuntime(deps) {
|
||||
const {
|
||||
processEnv,
|
||||
CLAUDE_PATH,
|
||||
CODEX_PATH,
|
||||
MODEL_MAP,
|
||||
loadModelConfig,
|
||||
applyCustomTemplateToSettings,
|
||||
loadCodexConfig,
|
||||
prepareCodexCustomRuntime,
|
||||
wsSend,
|
||||
truncateObj,
|
||||
sanitizeToolInput,
|
||||
loadSession,
|
||||
saveSession,
|
||||
setRuntimeSessionId,
|
||||
getRuntimeSessionId,
|
||||
} = deps;
|
||||
|
||||
function buildClaudeSpawnSpec(session, options = {}) {
|
||||
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
if (hasAttachments) args.push('--input-format', 'stream-json');
|
||||
const permMode = session.permissionMode || 'yolo';
|
||||
switch (permMode) {
|
||||
case 'yolo':
|
||||
args.push('--dangerously-skip-permissions');
|
||||
break;
|
||||
case 'plan':
|
||||
args.push('--permission-mode', 'plan');
|
||||
break;
|
||||
case 'default':
|
||||
break;
|
||||
}
|
||||
if (session.claudeSessionId) {
|
||||
args.push('--resume', session.claudeSessionId);
|
||||
}
|
||||
if (session.model) {
|
||||
const validModels = new Set(Object.values(MODEL_MAP));
|
||||
if (validModels.has(session.model)) {
|
||||
args.push('--model', session.model);
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...processEnv };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.startsWith('ANTHROPIC_')) delete env[k];
|
||||
}
|
||||
|
||||
const modelCfg = loadModelConfig();
|
||||
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
||||
const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate);
|
||||
if (tpl) applyCustomTemplateToSettings(tpl);
|
||||
}
|
||||
|
||||
return {
|
||||
command: CLAUDE_PATH,
|
||||
args,
|
||||
env,
|
||||
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
||||
parser: 'claude',
|
||||
mode: permMode,
|
||||
resume: !!session.claudeSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexSpawnSpec(session, options = {}) {
|
||||
const codexConfig = loadCodexConfig();
|
||||
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
|
||||
if (runtimeConfig?.error) {
|
||||
return { error: runtimeConfig.error };
|
||||
}
|
||||
const runtimeId = getRuntimeSessionId(session);
|
||||
const args = ['exec'];
|
||||
if (runtimeId) args.push('resume');
|
||||
args.push('--json', '--skip-git-repo-check');
|
||||
|
||||
const permMode = session.permissionMode || 'yolo';
|
||||
switch (permMode) {
|
||||
case 'yolo':
|
||||
args.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
break;
|
||||
case 'plan':
|
||||
args.push('-s', 'read-only');
|
||||
break;
|
||||
case 'default':
|
||||
default:
|
||||
args.push('--full-auto');
|
||||
break;
|
||||
}
|
||||
|
||||
const effectiveModel = session.model;
|
||||
if (effectiveModel) args.push('--model', effectiveModel);
|
||||
if (Array.isArray(options.attachments)) {
|
||||
for (const attachment of options.attachments) {
|
||||
if (attachment?.path) args.push('--image', attachment.path);
|
||||
}
|
||||
}
|
||||
if (runtimeId) {
|
||||
args.push(runtimeId, '-');
|
||||
} else {
|
||||
if (session.cwd) args.push('-C', session.cwd);
|
||||
args.push('-');
|
||||
}
|
||||
|
||||
const env = { ...processEnv };
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
if (runtimeConfig?.mode === 'custom') {
|
||||
env.CODEX_HOME = runtimeConfig.homeDir;
|
||||
env.OPENAI_API_KEY = runtimeConfig.apiKey;
|
||||
delete env.OPENAI_BASE_URL;
|
||||
}
|
||||
|
||||
return {
|
||||
command: CODEX_PATH,
|
||||
args,
|
||||
env,
|
||||
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
||||
parser: 'codex',
|
||||
mode: permMode,
|
||||
resume: !!runtimeId,
|
||||
};
|
||||
}
|
||||
|
||||
function codexToolName(item) {
|
||||
switch (item?.type) {
|
||||
case 'command_execution':
|
||||
return 'CommandExecution';
|
||||
case 'mcp_tool_call':
|
||||
return 'McpToolCall';
|
||||
case 'file_change':
|
||||
return 'FileChange';
|
||||
case 'reasoning':
|
||||
return 'Reasoning';
|
||||
default:
|
||||
return item?.type || 'CodexItem';
|
||||
}
|
||||
}
|
||||
|
||||
function codexToolInput(item) {
|
||||
if (!item) return null;
|
||||
if (item.type === 'command_execution') return { command: item.command || '' };
|
||||
return truncateObj(item, 500);
|
||||
}
|
||||
|
||||
function codexToolMeta(item) {
|
||||
if (!item) return null;
|
||||
switch (item.type) {
|
||||
case 'command_execution':
|
||||
return {
|
||||
kind: 'command_execution',
|
||||
title: 'Shell Command',
|
||||
subtitle: item.command || '',
|
||||
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'mcp_tool_call':
|
||||
return {
|
||||
kind: 'mcp_tool_call',
|
||||
title: 'MCP Tool',
|
||||
subtitle: item.tool_name || item.name || item.server_name || '',
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'file_change':
|
||||
return {
|
||||
kind: 'file_change',
|
||||
title: 'File Change',
|
||||
subtitle: item.path || item.file_path || '',
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
kind: 'reasoning',
|
||||
title: 'Reasoning',
|
||||
subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '',
|
||||
status: item.status || null,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: item.type || 'codex_item',
|
||||
title: codexToolName(item),
|
||||
subtitle: '',
|
||||
status: item.status || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function codexToolResult(item) {
|
||||
if (!item) return '';
|
||||
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
|
||||
if (typeof item.text === 'string' && item.text) return item.text;
|
||||
return JSON.stringify(truncateObj(item, 1200));
|
||||
}
|
||||
|
||||
function ensureCodexToolCall(entry, item) {
|
||||
let tc = entry.toolCalls.find((t) => t.id === item.id);
|
||||
if (tc) {
|
||||
tc.name = codexToolName(item);
|
||||
tc.kind = item.type || tc.kind || null;
|
||||
tc.meta = codexToolMeta(item) || tc.meta || null;
|
||||
if (tc.input == null) tc.input = codexToolInput(item);
|
||||
return tc;
|
||||
}
|
||||
tc = {
|
||||
name: codexToolName(item),
|
||||
id: item.id,
|
||||
kind: item.type || null,
|
||||
meta: codexToolMeta(item),
|
||||
input: codexToolInput(item),
|
||||
done: false,
|
||||
};
|
||||
entry.toolCalls.push(tc);
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_start',
|
||||
name: tc.name,
|
||||
toolUseId: item.id,
|
||||
input: tc.input,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
});
|
||||
return tc;
|
||||
}
|
||||
|
||||
function processClaudeEvent(entry, event, sessionId) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'system':
|
||||
if (event.session_id) {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
session.claudeSessionId = event.session_id;
|
||||
saveSession(session);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assistant': {
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) break;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
entry.fullText += block.text;
|
||||
wsSend(entry.ws, { type: 'text_delta', text: block.text });
|
||||
} else if (block.type === 'tool_use') {
|
||||
const toolInput = sanitizeToolInput(block.name, block.input);
|
||||
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
|
||||
entry.toolCalls.push(tc);
|
||||
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
|
||||
} else if (block.type === 'tool_result') {
|
||||
const resultText = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.map((c) => c.text || '').join('\n')
|
||||
: JSON.stringify(block.content);
|
||||
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
|
||||
if (tc) {
|
||||
tc.done = true;
|
||||
tc.result = resultText.slice(0, 2000);
|
||||
}
|
||||
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.session_id) {
|
||||
const session = loadSession(sessionId);
|
||||
if (session && !session.claudeSessionId) {
|
||||
session.claudeSessionId = event.session_id;
|
||||
saveSession(session);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
if (event.session_id) session.claudeSessionId = event.session_id;
|
||||
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
|
||||
saveSession(session);
|
||||
}
|
||||
entry.lastCost = event.total_cost_usd || null;
|
||||
if (entry.ws && event.total_cost_usd !== undefined) {
|
||||
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processCodexEvent(entry, event, sessionId) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'thread.started': {
|
||||
if (!event.thread_id) break;
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
setRuntimeSessionId(session, event.thread_id);
|
||||
saveSession(session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.started': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id || item.type === 'agent_message') break;
|
||||
ensureCodexToolCall(entry, item);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.completed': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id) break;
|
||||
if (item.type === 'agent_message') {
|
||||
if (item.text) {
|
||||
entry.fullText += item.text;
|
||||
wsSend(entry.ws, { type: 'text_delta', text: item.text });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const tc = ensureCodexToolCall(entry, item);
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
tc.done = true;
|
||||
tc.result = resultText;
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_end',
|
||||
toolUseId: item.id,
|
||||
result: resultText,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'turn.completed': {
|
||||
const usage = event.usage || null;
|
||||
entry.lastUsage = usage;
|
||||
const session = loadSession(sessionId);
|
||||
if (session && usage) {
|
||||
session.totalUsage = {
|
||||
inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0),
|
||||
cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0),
|
||||
outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0),
|
||||
};
|
||||
saveSession(session);
|
||||
wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'turn.failed': {
|
||||
const message = event.error?.message || 'Codex 任务失败';
|
||||
entry.lastError = message;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
if (event.message) {
|
||||
if (/^Reconnecting\.\.\./.test(event.message)) {
|
||||
wsSend(entry.ws, { type: 'system_message', message: event.message });
|
||||
} else {
|
||||
entry.lastError = event.message;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function processRuntimeEvent(entry, event, sessionId) {
|
||||
if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId);
|
||||
else processClaudeEvent(entry, event, sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
buildClaudeSpawnSpec,
|
||||
buildCodexSpawnSpec,
|
||||
processClaudeEvent,
|
||||
processCodexEvent,
|
||||
processRuntimeEvent,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createAgentRuntime };
|
||||
205
lib/codex-rollouts.js
Normal file
205
lib/codex-rollouts.js
Normal file
@@ -0,0 +1,205 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function createCodexRolloutStore(deps) {
|
||||
const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps;
|
||||
|
||||
function extractCodexMessageText(content) {
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
|
||||
.map((item) => item.text || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function appendAssistantContent(turn, text) {
|
||||
if (!turn || !text || !text.trim()) return;
|
||||
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
|
||||
}
|
||||
|
||||
function parseCodexRolloutLines(lines) {
|
||||
const messages = [];
|
||||
const pendingToolCalls = new Map();
|
||||
const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null };
|
||||
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||||
let currentAssistant = null;
|
||||
let sawRealUserMessage = false;
|
||||
const fallbackUserMessages = [];
|
||||
|
||||
function ensureAssistant(ts) {
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
|
||||
} else if (!currentAssistant.timestamp && ts) {
|
||||
currentAssistant.timestamp = ts;
|
||||
}
|
||||
return currentAssistant;
|
||||
}
|
||||
|
||||
function flushAssistant() {
|
||||
if (!currentAssistant) return;
|
||||
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
|
||||
messages.push(currentAssistant);
|
||||
}
|
||||
currentAssistant = null;
|
||||
pendingToolCalls.clear();
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(trimmed); } catch { continue; }
|
||||
const ts = entry.timestamp || null;
|
||||
if (ts) meta.updatedAt = ts;
|
||||
|
||||
if (entry.type === 'session_meta') {
|
||||
meta.threadId = entry.payload?.id || meta.threadId;
|
||||
meta.cwd = entry.payload?.cwd || meta.cwd;
|
||||
meta.cliVersion = entry.payload?.cli_version || meta.cliVersion;
|
||||
meta.source = entry.payload?.source || meta.source;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
|
||||
const total = entry.payload?.info?.total_token_usage || null;
|
||||
const usage = entry.payload?.info?.last_token_usage || null;
|
||||
if (total) {
|
||||
totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0);
|
||||
totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0);
|
||||
totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0);
|
||||
} else if (usage) {
|
||||
totalUsage.inputTokens += usage.input_tokens || 0;
|
||||
totalUsage.cachedInputTokens += usage.cached_input_tokens || 0;
|
||||
totalUsage.outputTokens += usage.output_tokens || 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
const text = String(entry.payload?.message || '').trim();
|
||||
if (text) {
|
||||
sawRealUserMessage = true;
|
||||
flushAssistant();
|
||||
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
|
||||
messages.push({ role: 'user', content: text, timestamp: ts });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== 'response_item') continue;
|
||||
|
||||
const payload = entry.payload || {};
|
||||
switch (payload.type) {
|
||||
case 'message': {
|
||||
if (payload.role === 'assistant') {
|
||||
const text = extractCodexMessageText(payload.content);
|
||||
if (text.trim()) {
|
||||
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
|
||||
flushAssistant();
|
||||
}
|
||||
appendAssistantContent(ensureAssistant(ts), text);
|
||||
}
|
||||
} else if (payload.role === 'user' && !sawRealUserMessage) {
|
||||
const text = extractCodexMessageText(payload.content);
|
||||
if (text.trim()) {
|
||||
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'function_call': {
|
||||
const assistant = ensureAssistant(ts);
|
||||
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
|
||||
const tc = {
|
||||
name: payload.name || 'FunctionCall',
|
||||
id: toolUseId,
|
||||
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
|
||||
done: false,
|
||||
};
|
||||
assistant.toolCalls.push(tc);
|
||||
pendingToolCalls.set(toolUseId, tc);
|
||||
break;
|
||||
}
|
||||
case 'function_call_output': {
|
||||
const assistant = ensureAssistant(ts);
|
||||
const toolUseId = payload.call_id || crypto.randomUUID();
|
||||
let tc = pendingToolCalls.get(toolUseId);
|
||||
if (!tc) {
|
||||
tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false };
|
||||
assistant.toolCalls.push(tc);
|
||||
pendingToolCalls.set(toolUseId, tc);
|
||||
}
|
||||
tc.done = true;
|
||||
tc.result = (typeof payload.output === 'string'
|
||||
? payload.output
|
||||
: JSON.stringify(payload.output || '')).slice(0, 2000);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
|
||||
const fallback = fallbackUserMessages[0];
|
||||
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
|
||||
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
|
||||
}
|
||||
return { meta, messages, totalUsage };
|
||||
}
|
||||
|
||||
function walkFiles(dir, files = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkFiles(fullPath, files);
|
||||
else if (entry.isFile()) files.push(fullPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function getCodexRolloutFiles() {
|
||||
if (!fs.existsSync(codexSessionsDir)) return [];
|
||||
return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse();
|
||||
}
|
||||
|
||||
function getImportedCodexThreadIds() {
|
||||
const imported = new Set();
|
||||
try {
|
||||
for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) {
|
||||
try {
|
||||
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8')));
|
||||
if (session.codexThreadId) imported.add(session.codexThreadId);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return imported;
|
||||
}
|
||||
|
||||
function parseCodexRolloutFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseCodexRolloutLines(content.split('\n'));
|
||||
parsed.filePath = filePath;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parseCodexRolloutLines,
|
||||
getCodexRolloutFiles,
|
||||
getImportedCodexThreadIds,
|
||||
parseCodexRolloutFile,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createCodexRolloutStore };
|
||||
Reference in New Issue
Block a user