Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
275 lines
10 KiB
Python
275 lines
10 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from amc_server.context import (
|
|
CLAUDE_PROJECTS_DIR,
|
|
CODEX_SESSIONS_DIR,
|
|
_CODEX_CACHE_MAX,
|
|
_CONTEXT_CACHE_MAX,
|
|
_codex_transcript_cache,
|
|
_context_usage_cache,
|
|
)
|
|
from amc_server.logging_utils import LOGGER
|
|
|
|
|
|
class SessionParsingMixin:
|
|
def _get_claude_conversation_file(self, session_id, project_dir):
|
|
"""Resolve Claude conversation file path from session id + project dir."""
|
|
if not project_dir:
|
|
return None
|
|
|
|
encoded_dir = project_dir.replace("/", "-")
|
|
if not encoded_dir.startswith("-"):
|
|
encoded_dir = "-" + encoded_dir
|
|
|
|
conv_file = CLAUDE_PROJECTS_DIR / encoded_dir / f"{session_id}.jsonl"
|
|
return conv_file if conv_file.exists() else None
|
|
|
|
def _find_codex_transcript_file(self, session_id):
|
|
"""Resolve Codex transcript path for a session id with lightweight caching."""
|
|
if not session_id:
|
|
return None
|
|
|
|
if session_id in _codex_transcript_cache:
|
|
cached = _codex_transcript_cache.get(session_id)
|
|
if cached:
|
|
path = Path(cached)
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
if not CODEX_SESSIONS_DIR.exists():
|
|
_codex_transcript_cache[session_id] = None
|
|
return None
|
|
|
|
try:
|
|
for jsonl_file in CODEX_SESSIONS_DIR.rglob(f"*{session_id}*.jsonl"):
|
|
# Evict old entries if cache is full (simple FIFO)
|
|
if len(_codex_transcript_cache) >= _CODEX_CACHE_MAX:
|
|
keys_to_remove = list(_codex_transcript_cache.keys())[: _CODEX_CACHE_MAX // 5]
|
|
for k in keys_to_remove:
|
|
_codex_transcript_cache.pop(k, None)
|
|
_codex_transcript_cache[session_id] = str(jsonl_file)
|
|
return jsonl_file
|
|
except OSError:
|
|
pass
|
|
|
|
_codex_transcript_cache[session_id] = None
|
|
return None
|
|
|
|
def _read_jsonl_tail_entries(self, file_path, max_lines=300, max_bytes=1024 * 1024):
|
|
"""Read only the tail of a JSONL file and return parsed entries."""
|
|
entries = []
|
|
|
|
try:
|
|
with file_path.open("rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
file_size = f.tell()
|
|
if file_size <= 0:
|
|
return entries
|
|
|
|
read_size = min(file_size, max_bytes)
|
|
f.seek(file_size - read_size)
|
|
chunk = f.read(read_size)
|
|
except OSError:
|
|
return entries
|
|
|
|
lines = chunk.splitlines()
|
|
if file_size > read_size and lines:
|
|
# First line may be partial because we started in the middle.
|
|
lines = lines[1:]
|
|
|
|
for raw_line in lines[-max_lines:]:
|
|
if not raw_line:
|
|
continue
|
|
try:
|
|
entries.append(json.loads(raw_line.decode("utf-8", errors="replace")))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
return entries
|
|
|
|
def _to_int(self, value):
|
|
"""Best-effort integer conversion."""
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, float):
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
def _sum_optional_ints(self, values):
|
|
"""Sum available ints, return None when no values are present."""
|
|
present = [v for v in values if isinstance(v, int)]
|
|
if not present:
|
|
return None
|
|
return sum(present)
|
|
|
|
def _as_dict(self, value):
|
|
"""Normalize potentially-null JSON objects into dicts."""
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
def _parse_codex_context_usage_from_file(self, file_path):
|
|
"""Extract the latest Codex context usage snapshot from transcript tail."""
|
|
entries = self._read_jsonl_tail_entries(file_path, max_lines=600, max_bytes=1024 * 1024)
|
|
|
|
for entry in reversed(entries):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("type") != "event_msg":
|
|
continue
|
|
payload = self._as_dict(entry.get("payload"))
|
|
if payload.get("type") != "token_count":
|
|
continue
|
|
|
|
info = self._as_dict(payload.get("info"))
|
|
last_usage = self._as_dict(info.get("last_token_usage"))
|
|
total_usage = self._as_dict(info.get("total_token_usage"))
|
|
|
|
input_tokens = self._to_int(last_usage.get("input_tokens"))
|
|
output_tokens = self._to_int(last_usage.get("output_tokens"))
|
|
cached_input_tokens = self._to_int(last_usage.get("cached_input_tokens"))
|
|
current_tokens = self._to_int(last_usage.get("total_tokens"))
|
|
if current_tokens is None:
|
|
current_tokens = self._sum_optional_ints([input_tokens, output_tokens, cached_input_tokens])
|
|
|
|
usage = {
|
|
"window_tokens": self._to_int(info.get("model_context_window")),
|
|
"current_tokens": current_tokens,
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
"cached_input_tokens": cached_input_tokens,
|
|
"session_total_tokens": self._to_int(total_usage.get("total_tokens")),
|
|
"updated_at": entry.get("timestamp", ""),
|
|
}
|
|
|
|
if usage["current_tokens"] is None and usage["session_total_tokens"] is None:
|
|
continue
|
|
return usage
|
|
|
|
return None
|
|
|
|
def _get_claude_context_window(self, model):
|
|
"""Return context window size for Claude models."""
|
|
if not model:
|
|
return 200_000 # Default for unknown Claude models
|
|
# All current Claude 3.5/4 models have 200K context
|
|
# Legacy claude-3-opus/sonnet/haiku also 200K, claude-2 was 100K
|
|
if "claude-2" in model:
|
|
return 100_000
|
|
return 200_000
|
|
|
|
def _parse_claude_context_usage_from_file(self, file_path):
|
|
"""Extract Claude usage with context window."""
|
|
entries = self._read_jsonl_tail_entries(file_path, max_lines=400, max_bytes=1024 * 1024)
|
|
|
|
for entry in reversed(entries):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("type") != "assistant":
|
|
continue
|
|
|
|
message = self._as_dict(entry.get("message"))
|
|
usage = self._as_dict(message.get("usage"))
|
|
if not usage:
|
|
continue
|
|
|
|
input_tokens = self._to_int(usage.get("input_tokens"))
|
|
output_tokens = self._to_int(usage.get("output_tokens"))
|
|
cache_read_input_tokens = self._to_int(usage.get("cache_read_input_tokens"))
|
|
cache_creation_input_tokens = self._to_int(usage.get("cache_creation_input_tokens"))
|
|
cached_input_tokens = self._sum_optional_ints([
|
|
cache_read_input_tokens,
|
|
cache_creation_input_tokens,
|
|
])
|
|
current_tokens = self._sum_optional_ints([
|
|
input_tokens,
|
|
output_tokens,
|
|
cache_read_input_tokens,
|
|
cache_creation_input_tokens,
|
|
])
|
|
|
|
if current_tokens is None:
|
|
continue
|
|
|
|
model = message.get("model", "")
|
|
return {
|
|
"window_tokens": self._get_claude_context_window(model),
|
|
"current_tokens": current_tokens,
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
"cached_input_tokens": cached_input_tokens,
|
|
"session_total_tokens": None,
|
|
"updated_at": entry.get("timestamp", ""),
|
|
"model": model,
|
|
}
|
|
|
|
return None
|
|
|
|
def _get_cached_context_usage(self, file_path, parser):
|
|
"""Cache parsed usage by transcript mtime/size for poll efficiency."""
|
|
try:
|
|
stat = file_path.stat()
|
|
except OSError:
|
|
return None
|
|
|
|
key = str(file_path)
|
|
cached = _context_usage_cache.get(key)
|
|
if cached and cached.get("mtime_ns") == stat.st_mtime_ns and cached.get("size") == stat.st_size:
|
|
return cached.get("usage")
|
|
|
|
try:
|
|
usage = parser(file_path)
|
|
except Exception:
|
|
LOGGER.exception("Failed to parse context usage for %s", file_path)
|
|
usage = None
|
|
|
|
# Evict oldest entries if cache is full (simple FIFO)
|
|
if len(_context_usage_cache) >= _CONTEXT_CACHE_MAX:
|
|
keys_to_remove = list(_context_usage_cache.keys())[: _CONTEXT_CACHE_MAX // 5]
|
|
for k in keys_to_remove:
|
|
_context_usage_cache.pop(k, None)
|
|
|
|
_context_usage_cache[key] = {
|
|
"mtime_ns": stat.st_mtime_ns,
|
|
"size": stat.st_size,
|
|
"usage": usage,
|
|
}
|
|
return usage
|
|
|
|
def _get_context_usage_for_session(self, session_data):
|
|
"""Attach context/token usage info for both Codex and Claude sessions."""
|
|
agent = session_data.get("agent")
|
|
existing = session_data.get("context_usage")
|
|
|
|
if agent == "codex":
|
|
transcript_path = session_data.get("transcript_path", "")
|
|
transcript_file = Path(transcript_path) if transcript_path else None
|
|
if transcript_file and not transcript_file.exists():
|
|
transcript_file = None
|
|
if not transcript_file:
|
|
transcript_file = self._find_codex_transcript_file(session_data.get("session_id", ""))
|
|
if not transcript_file:
|
|
return existing
|
|
parsed = self._get_cached_context_usage(transcript_file, self._parse_codex_context_usage_from_file)
|
|
return parsed or existing
|
|
|
|
if agent == "claude":
|
|
conv_file = self._get_claude_conversation_file(
|
|
session_data.get("session_id", ""),
|
|
session_data.get("project_dir", ""),
|
|
)
|
|
if not conv_file:
|
|
return existing
|
|
parsed = self._get_cached_context_usage(conv_file, self._parse_claude_context_usage_from_file)
|
|
return parsed or existing
|
|
|
|
return existing
|