Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ codex exec --json -m {model} -C {cwd} --skip-git-repo-check \
codex exec resume {sessionId} --json -m {model} --skip-git-repo-check "{prompt}"
```
(`exec resume` does not use `-C` or `-s`.)
Image attachments are passed via `-i /path/to/image`; non-image attachments are appended to the prompt as readable file paths.

**Ollama Provider:** Stateless HTTP requests to Ollama API:
- POST to `/api/chat` with full message history
Expand Down Expand Up @@ -241,6 +242,8 @@ Conversations default to sandboxed mode for safety. Sandbox configuration:
| `GET` | `/api/files/content` | Get structured file content (standalone cwd) |
| `GET` | `/api/files/download` | Download file |
| `POST` | `/api/files/upload` | Upload file |
| `POST` | `/api/conversations/:id/upload` | Upload local file as conversation attachment |
| `POST` | `/api/conversations/:id/attachments/from-files` | Copy existing cwd file(s) into conversation attachments |
| `GET` | `/api/conversations/:id/files` | List files in cwd |
| `GET` | `/api/conversations/:id/files/content` | Get file content |
| `GET` | `/api/conversations/:id/files/search` | Git grep search |
Expand Down Expand Up @@ -371,7 +374,7 @@ public/js/
Five mutually exclusive views with CSS transform transitions:

1. **List View** — Conversation browser grouped by cwd, search (keyword + semantic), archive toggle
2. **Chat View** — Messages, input bar, file panel with preview
2. **Chat View** — Messages, input bar, file panel with preview, and direct file-to-chat attachment from file tree/viewer actions
3. **Stats View** — Analytics dashboard with cost tracking, activity charts
4. **Branches View** — Fork tree visualization with parent/child navigation
5. **Memory View** — Memory management (global + project-scoped)
Expand Down
13 changes: 9 additions & 4 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public/css/
role: 'user',
text: string,
timestamp: number,
attachments: [{ filename, url }] // optional
attachments: [{ path, filename, url }] // optional
}
```

Expand All @@ -139,8 +139,13 @@ public/css/
cost: number, // USD
duration: number, // ms
sessionId: string,
inputTokens: number,
outputTokens: number
inputTokens: number, // Provider-reported input token count (raw when available)
outputTokens: number,
netInputTokens?: number, // rawInputTokens - cachedInputTokens
typedInputTokens?: number, // rough estimate of user-typed prompt tokens
rawInputTokens?: number, // full provider-reported prompt/context tokens
cachedInputTokens?: number, // provider cache hit tokens (if available)
reasoningTokens?: number // provider-reported reasoning/thinking tokens
}
```

Expand Down Expand Up @@ -235,7 +240,7 @@ public/css/
| `loadConversations()` | conversations.js | Fetch + render list |
| `openConversation(id)` | conversations.js | Load + display conversation |
| `forkConversation(idx)` | conversations.js | Fork from message |
| `sendMessage(text)` | ui.js | Send with attachments |
| `sendMessage(text)` | ui.js | Send with local uploads or pre-attached server files |
| `renderMarkdown(text)` | markdown.js | Markdown → HTML |

---
Expand Down
35 changes: 34 additions & 1 deletion lib/providers/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) {
return inputCost + outputCost;
}

function estimateTypedInputTokens(text) {
return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0;
}

// Active Claude processes per conversation
const activeProcesses = new Map();
const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes
Expand Down Expand Up @@ -190,6 +194,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options =
let totalChars = 0;
for (let i = priorMessages.length - 1; i >= 0; i--) {
const msg = priorMessages[i];
if (msg?.summarized) continue;
let text = String(msg?.text || '').trim();
for (const replacement of pathReplacements) {
text = text.split(replacement.from).join(replacement.to);
Expand Down Expand Up @@ -376,8 +381,20 @@ function handleResultEvent(
conv.claudeForkSessionId = null;
}

const inputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0;
const rawInputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0;
const cachedInputTokens =
event.cache_read_input_tokens ??
event.usage?.cache_read_input_tokens ??
event.usage?.input_tokens_details?.cached_tokens ??
0;
const netInputTokens = Math.max(0, rawInputTokens - cachedInputTokens);
const inputTokens = rawInputTokens;
const typedInputTokens = conv._typedInputTokens ?? null;
const outputTokens = event.total_output_tokens ?? event.output_tokens ?? event.usage?.output_tokens ?? 0;
const reasoningTokens =
event.usage?.output_tokens_details?.reasoning_tokens ??
event.usage?.completion_tokens_details?.reasoning_tokens ??
0;
if (!resultText.trim() && inputTokens === 0 && outputTokens === 0) {
if (canRetryWithFreshSession) {
// Resumed sessions can occasionally become invalid and return empty 0/0.
Expand Down Expand Up @@ -417,7 +434,12 @@ function handleResultEvent(
duration: event.duration_ms,
sessionId: event.session_id,
inputTokens,
netInputTokens,
typedInputTokens,
outputTokens,
reasoningTokens,
rawInputTokens,
cachedInputTokens,
});
conv.status = 'idle';
conv.thinkingStartTime = null;
Expand All @@ -437,8 +459,14 @@ function handleResultEvent(
duration: event.duration_ms,
sessionId: event.session_id,
inputTokens,
netInputTokens,
typedInputTokens,
outputTokens,
reasoningTokens,
rawInputTokens,
cachedInputTokens,
});
delete conv._typedInputTokens;
broadcastStatus(conversationId, 'idle');
}

Expand Down Expand Up @@ -542,6 +570,7 @@ class ClaudeProvider extends LLMProvider {
const promptText = inlineHistory
? `${inlineHistory}\n\n[New user message]\n${text}`
: text;
conv._typedInputTokens = estimateTypedInputTokens(text);

const args = [
'-p', promptText,
Expand Down Expand Up @@ -741,6 +770,7 @@ Please continue the conversation naturally, using the above context as backgroun
) {
retryScheduled = true;
delete conv._retryAfterEmptyResultMode;
delete conv._typedInputTokens;
void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, {
...runtime,
retried: true,
Expand Down Expand Up @@ -776,6 +806,7 @@ Please continue the conversation naturally, using the above context as backgroun
isSlashOnlyPrompt,
});
delete conv._retryAfterEmptyResultMode;
delete conv._typedInputTokens;
});

proc.on('error', (err) => {
Expand All @@ -784,6 +815,7 @@ Please continue the conversation naturally, using the above context as backgroun

if (!retryScheduled && conv.status === 'thinking' && canRetryWithCompactHistory && isContextOverflowError(err?.message || err?.code)) {
retryScheduled = true;
delete conv._typedInputTokens;
void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, {
...runtime,
retried: true,
Expand All @@ -801,6 +833,7 @@ Please continue the conversation naturally, using the above context as backgroun
});
broadcastStatus(conversationId, 'idle');
delete conv._retryAfterEmptyResultMode;
delete conv._typedInputTokens;
});
}

Expand Down
66 changes: 61 additions & 5 deletions lib/providers/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) {
return inputCost + outputCost;
}

function estimateTypedInputTokens(text) {
return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0;
}

// Active Codex processes per conversation
const activeProcesses = new Map();
const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes
Expand Down Expand Up @@ -112,6 +116,30 @@ function formatMemoriesForPrompt(memories) {
return `\n\n[User memories]\n${sections.join('\n\n')}\n[/User memories]`;
}

function partitionAttachments(attachments = []) {
const imageAttachments = [];
const fileAttachments = [];

for (const attachment of attachments || []) {
const filePath = attachment?.path;
if (!filePath || typeof filePath !== 'string') continue;

if (/\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) {
imageAttachments.push(attachment);
} else {
fileAttachments.push(attachment);
}
}

return { imageAttachments, fileAttachments };
}

function buildFileAttachmentPrompt(fileAttachments = []) {
if (!Array.isArray(fileAttachments) || fileAttachments.length === 0) return '';
const paths = fileAttachments.map((a) => a.path).join('\n');
return `\n\n[Attached file${fileAttachments.length > 1 ? 's' : ''} — read for context:]\n${paths}`;
}

function buildInlineHistoryContext(messages = [], latestUserText = '', options = {}) {
if (!Array.isArray(messages) || messages.length === 0) return '';
const maxChars = Number.isFinite(Number(options.maxChars)) && Number(options.maxChars) > 0
Expand All @@ -128,6 +156,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options =
let totalChars = 0;
for (let i = priorMessages.length - 1; i >= 0; i--) {
const msg = priorMessages[i];
if (msg?.summarized) continue;
const text = String(msg?.text || '').trim();
if (!text) continue;

Expand Down Expand Up @@ -496,8 +525,14 @@ function processCodexEvent(
0;
// For UX, show net new input tokens instead of full cached prompt tokens.
const inputTokens = Math.max(0, rawInputTokens - cachedInputTokens);
const netInputTokens = inputTokens;
const displayInputTokens = conv._codexDisplayInputTokens ?? inputTokens;
const typedInputTokens = conv._typedInputTokens ?? displayInputTokens;
const outputTokens = usage.output_tokens || 0;
const reasoningTokens =
usage.output_tokens_details?.reasoning_tokens ??
usage.completion_tokens_details?.reasoning_tokens ??
0;
if (!assistantText.trim() && inputTokens === 0 && outputTokens === 0) {
if (canRetryWithFreshSession) {
// Resumed sessions can occasionally become invalid and return empty 0/0.
Expand All @@ -522,6 +557,7 @@ function processCodexEvent(
error: 'Model returned an empty response. Please retry.',
});
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
broadcastStatus(conversationId, 'idle');
break;
}
Expand All @@ -538,8 +574,11 @@ function processCodexEvent(
duration: event.duration_ms,
sessionId: conv.codexSessionId,
inputTokens,
netInputTokens,
displayInputTokens,
typedInputTokens,
outputTokens,
reasoningTokens,
rawInputTokens,
cachedInputTokens,
});
Expand All @@ -555,12 +594,16 @@ function processCodexEvent(
duration: event.duration_ms,
sessionId: conv.codexSessionId,
inputTokens,
netInputTokens,
displayInputTokens,
typedInputTokens,
outputTokens,
reasoningTokens,
rawInputTokens,
cachedInputTokens,
});
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
broadcastStatus(conversationId, 'idle');
break;
}
Expand Down Expand Up @@ -623,14 +666,15 @@ class CodexProvider extends LLMProvider {
args.push('-s', allowWrites ? 'workspace-write' : 'read-only');
}

const { imageAttachments, fileAttachments } = partitionAttachments(attachments);

// Image attachments
const images = (attachments || []).filter(a => /\.(png|jpg|jpeg|gif|webp)$/i.test(a.path));
for (const img of images) {
for (const img of imageAttachments) {
args.push('-i', img.path);
}

// Grant access to uploads directory
if (!isResume && attachments && attachments.length > 0) {
if (!isResume && (imageAttachments.length > 0 || fileAttachments.length > 0)) {
args.push('--add-dir', path.join(uploadDir, conversationId));
}

Expand All @@ -651,8 +695,11 @@ class CodexProvider extends LLMProvider {
const promptBase = inlineHistory
? `${inlineHistory}\n\n[New user message]\n${text}`
: text;
const prompt = promptBase + formatMemoriesForPrompt(enabledMemories);
conv._codexDisplayInputTokens = text ? Math.max(1, Math.ceil(text.length / 4)) : 0;
const prompt = promptBase
+ buildFileAttachmentPrompt(fileAttachments)
+ formatMemoriesForPrompt(enabledMemories);
conv._typedInputTokens = estimateTypedInputTokens(text);
conv._codexDisplayInputTokens = conv._typedInputTokens;

// Prompt must be last
args.push(prompt);
Expand Down Expand Up @@ -758,6 +805,7 @@ class CodexProvider extends LLMProvider {
delete conv._codexOpenToolIds;
delete conv._codexOpenTraceCount;
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, {
...runtime,
retried: true,
Expand Down Expand Up @@ -797,6 +845,7 @@ class CodexProvider extends LLMProvider {
delete conv._codexOpenToolIds;
delete conv._codexOpenTraceCount;
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
});

proc.on('error', (err) => {
Expand All @@ -808,6 +857,7 @@ class CodexProvider extends LLMProvider {
delete conv._codexOpenToolIds;
delete conv._codexOpenTraceCount;
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, {
...runtime,
retried: true,
Expand All @@ -828,6 +878,7 @@ class CodexProvider extends LLMProvider {
delete conv._codexOpenToolIds;
delete conv._codexOpenTraceCount;
delete conv._codexDisplayInputTokens;
delete conv._typedInputTokens;
});
}

Expand Down Expand Up @@ -951,3 +1002,8 @@ module.exports.MODELS = MODELS;
module.exports.activeProcesses = activeProcesses;
module.exports.processCodexEvent = processCodexEvent;
module.exports.handleNoOutputClose = handleNoOutputClose;
module.exports.partitionAttachments = partitionAttachments;
module.exports.buildFileAttachmentPrompt = buildFileAttachmentPrompt;
module.exports._private = {
buildInlineHistoryContext,
};
14 changes: 14 additions & 0 deletions lib/providers/ollama.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
// Active requests per conversation (for cancellation)
const activeRequests = new Map();

function estimateTypedInputTokens(text) {
return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0;
}

// Default models if Ollama is not available
const DEFAULT_MODELS = [
{ id: 'llama3.2', name: 'Llama 3.2', context: 128000 },
Expand Down Expand Up @@ -122,6 +126,7 @@ ${summaryMsg.text}
*/
async chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories = []) {
const { onSave, broadcastStatus } = callbacks;
conv._typedInputTokens = estimateTypedInputTokens(text);

// Note: Ollama doesn't support file attachments natively
// We could read file contents and append to the message, but skip for now
Expand Down Expand Up @@ -198,7 +203,11 @@ ${summaryMsg.text}
cost: 0, // Ollama is free/local
duration,
inputTokens: json.prompt_eval_count || 0,
netInputTokens: json.prompt_eval_count || 0,
typedInputTokens: conv._typedInputTokens ?? null,
outputTokens: json.eval_count || 0,
rawInputTokens: json.prompt_eval_count || 0,
cachedInputTokens: 0,
});
conv.status = 'idle';
conv.thinkingStartTime = null;
Expand All @@ -211,7 +220,11 @@ ${summaryMsg.text}
cost: 0,
duration,
inputTokens: json.prompt_eval_count || 0,
netInputTokens: json.prompt_eval_count || 0,
typedInputTokens: conv._typedInputTokens ?? null,
outputTokens: json.eval_count || 0,
rawInputTokens: json.prompt_eval_count || 0,
cachedInputTokens: 0,
});
broadcastStatus(conversationId, 'idle');
}
Expand Down Expand Up @@ -249,6 +262,7 @@ ${summaryMsg.text}
broadcastStatus(conversationId, 'idle');
} finally {
activeRequests.delete(conversationId);
delete conv._typedInputTokens;
}
}

Expand Down
Loading