diff --git a/.gitignore b/.gitignore index 8b377089..c3374209 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,6 @@ timing_logs.csv config.toml .aider* + +CRUSH.md +.crush/ diff --git a/CLAUDE.md b/CLAUDE.md index 803c285f..4adce6ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + # Honcho Overview ## What is Honcho? @@ -100,28 +104,64 @@ All API routes follow the pattern: `/v1/{resource}/{id}/{action}` ``` src/ -├── main.py # FastAPI app setup with middleware and exception handlers -├── models.py # SQLAlchemy ORM models with proper type annotations -├── schemas.py # Pydantic validation schemas for API -├── crud.py # Database operations -├── dependencies.py # Dependency injection (DB sessions) -├── exceptions.py # Custom exception types -├── security.py # JWT authentication -├── agent.py # Dialectic API implementation -├── routers/ # API endpoints +├── main.py # FastAPI app setup with middleware and exception handlers +├── models.py # SQLAlchemy ORM models with proper type annotations +├── schemas.py # Pydantic validation schemas for API +├── config.py # Configuration management +├── db.py # Database connection and session management +├── dependencies.py # Dependency injection (DB sessions) +├── exceptions.py # Custom exception types +├── security.py # JWT authentication +├── embedding_client.py # Embedding service client +├── crud/ # Database operations +│ ├── __init__.py +│ ├── collection.py # Collection CRUD operations +│ ├── deriver.py # Deriver-related CRUD operations +│ ├── document.py # Document CRUD operations +│ ├── message.py # Message CRUD operations +│ ├── peer.py # Peer CRUD operations +│ ├── representation.py # Representation CRUD operations +│ ├── session.py # Session CRUD operations +│ ├── webhook.py # Webhook CRUD operations +│ └── workspace.py # Workspace CRUD operations +├── dialectic/ # Dialectic API implementation +│ ├── __init__.py +│ ├── chat.py # Chat functionality +│ ├── prompts.py # Prompt templates +│ └── utils.py # Dialectic utilities +├── routers/ # API endpoints │ ├── workspaces.py │ ├── peers.py │ ├── sessions.py │ ├── messages.py -│ └── keys.py -├── deriver/ # Background processing system -│ ├── consumer.py # Message processing logic -│ ├── queue.py # Queue management -│ └── tom/ # Theory of Mind implementations -└── utils/ # Utilities - ├── history.py # Session history management - ├── cache.py # Caching utilities - └── model_client.py # LLM client abstraction +│ ├── keys.py +│ └── webhooks.py # Webhook endpoints +├── deriver/ # Background processing system +│ ├── __init__.py +│ ├── __main__.py # Deriver entry point +│ ├── consumer.py # Message consumer +│ ├── deriver.py # Main deriver logic +│ ├── enqueue.py # Queue operations +│ ├── prompts.py # Deriver prompts +│ ├── queue_manager.py # Queue management +│ ├── queue_payload.py # Queue payload schemas +│ └── utils.py # Deriver utilities +├── utils/ # Utilities +│ ├── __init__.py +│ ├── clients.py # LLM client abstraction +│ ├── embedding_store.py # Vector storage management +│ ├── files.py # File handling utilities +│ ├── filter.py # Query filtering utilities +│ ├── formatting.py # Message formatting utilities +│ ├── logging.py # Logging configuration +│ ├── search.py # Search functionality +│ ├── shared_models.py # Shared data models +│ ├── summarizer.py # Session summarization +│ └── types.py # Type definitions +└── webhooks/ # Webhook system + ├── events.py # Webhook event definitions + ├── webhook_delivery.py # Webhook delivery logic + └── README.md # Webhook documentation ``` - Tests in pytest with fixtures in tests/conftest.py diff --git a/src/config.py b/src/config.py index 3f7ea425..24b64763 100644 --- a/src/config.py +++ b/src/config.py @@ -194,6 +194,11 @@ class DeriverSettings(HonchoSettings): # Thinking budget tokens are only applied when using Anthropic as provider THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024 + # Context token limit for get_context method + CONTEXT_TOKEN_LIMIT: Annotated[int, Field(default=30_000, gt=1000, le=100_000)] = ( + 30_000 + ) + class DialecticSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="DIALECTIC_", extra="ignore") # pyright: ignore diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 48d92ae5..73597457 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -169,12 +169,14 @@ class Deriver: # created_at is now always a datetime object from Pydantic validation message_dt_obj = created_at - formatted_history = await summarizer.get_summarized_history( + # Use get_session_context_formatted with configurable token limit + formatted_history = await summarizer.get_session_context_formatted( db, workspace_name, session_name, + token_limit=settings.DERIVER.CONTEXT_TOKEN_LIMIT, cutoff=message_id, - summary_type=summarizer.SummaryType.SHORT, + include_summary=True, ) # instantiate embedding store from collection diff --git a/src/routers/sessions.py b/src/routers/sessions.py index f93b7f04..e8fbbf29 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -388,56 +388,13 @@ async def get_session_context( """ token_limit = tokens or config.settings.GET_CONTEXT_MAX_TOKENS - summary_content = "" - messages_tokens = token_limit - messages_start_id = 0 - - if summary: - summary_tokens_limit = token_limit * 0.4 - - latest_short_summary, latest_long_summary = await summarizer.get_both_summaries( - db, - workspace_name=workspace_id, - session_name=session_id, - ) - - long_len = latest_long_summary["token_count"] if latest_long_summary else 0 - short_len = latest_short_summary["token_count"] if latest_short_summary else 0 - - # The goal is to return the longest summary that fits within the token limit - # Sometimes (rarely) the short summary can be longer than the long summary, - # so we need to check for that and return the longer one. - - if ( - latest_long_summary - and long_len <= summary_tokens_limit - and long_len > short_len - ): - summary_content = latest_long_summary["content"] - messages_tokens = token_limit - latest_long_summary["token_count"] - messages_start_id = latest_long_summary["message_id"] - elif ( - latest_short_summary and short_len <= summary_tokens_limit and short_len > 0 - ): - summary_content = latest_short_summary["content"] - messages_tokens = token_limit - latest_short_summary["token_count"] - messages_start_id = latest_short_summary["message_id"] - else: - logger.info( - "No summary available for get_context call with token limit %s, returning empty string. long_summary_len: %s, short_summary_len: %s", - token_limit, - long_len, - short_len, - ) - summary_content = "" - - # Get the recent messages after summary to return verbatim - messages = await crud.get_messages_id_range( + # Use the shared get_session_context function from summarizer + summary_content, messages = await summarizer.get_session_context( db, workspace_name=workspace_id, session_name=session_id, - start_id=messages_start_id, - token_limit=messages_tokens, + token_limit=token_limit, + include_summary=summary, ) return schemas.SessionContext( diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py index e90547ae..352dec23 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -45,6 +45,8 @@ __all__ = [ "get_summary", "get_both_summaries", "get_summarized_history", + "get_session_context", + "get_session_context_formatted", "SummaryType", "Summary", ] @@ -554,6 +556,126 @@ async def get_both_summaries( return summaries.get(SummaryType.SHORT.value), summaries.get(SummaryType.LONG.value) +async def get_session_context( + db: AsyncSession, + workspace_name: str, + session_name: str, + token_limit: int, + *, + cutoff: int | None = None, + include_summary: bool = True, +) -> tuple[str, list[models.Message]]: + """ + Get session context similar to the API endpoint but for internal use. + + Args: + db: Database session + workspace_name: The workspace name + session_name: The session name + token_limit: Maximum tokens for the context + cutoff: Optional message ID to stop at (exclusive) + include_summary: Whether to include summary if available + + Returns: + Tuple of (summary_content, messages) where summary_content is the summary text (or empty string) + and messages is the list of message objects + """ + summary_content = "" + messages_tokens = token_limit + messages_start_id = 0 + + if include_summary: + # Allocate 40% of tokens to summary, 60% to messages + summary_tokens_limit = int(token_limit * 0.4) + + latest_short_summary, latest_long_summary = await get_both_summaries( + db, workspace_name, session_name + ) + + long_len = latest_long_summary["token_count"] if latest_long_summary else 0 + short_len = latest_short_summary["token_count"] if latest_short_summary else 0 + + # Return the longest summary that fits within the token limit + if ( + latest_long_summary + and long_len <= summary_tokens_limit + and long_len > short_len + ): + summary_content = latest_long_summary["content"] + messages_tokens = token_limit - latest_long_summary["token_count"] + messages_start_id = latest_long_summary["message_id"] + elif ( + latest_short_summary and short_len <= summary_tokens_limit and short_len > 0 + ): + summary_content = latest_short_summary["content"] + messages_tokens = token_limit - latest_short_summary["token_count"] + messages_start_id = latest_short_summary["message_id"] + else: + logger.warning( + "No summary available for get_context call with token limit %s, returning empty string. long_summary_len: %s, short_summary_len: %s", + token_limit, + long_len, + short_len, + ) + + # Get recent messages after summary + messages = await crud.get_messages_id_range( + db, + workspace_name, + session_name, + start_id=messages_start_id, + end_id=cutoff, + token_limit=messages_tokens, + ) + + return summary_content, messages + + +async def get_session_context_formatted( + db: AsyncSession, + workspace_name: str, + session_name: str, + token_limit: int, + *, + cutoff: int | None = None, + include_summary: bool = True, +) -> str: + """ + Get formatted session context as a string for internal use (e.g., deriver). + + This is a convenience wrapper around get_session_context that formats + the output as a string. + """ + summary_content, messages = await get_session_context( + db, + workspace_name, + session_name, + token_limit, + cutoff=cutoff, + include_summary=include_summary, + ) + + # Format the messages + messages_text = _format_messages(messages) + + if summary_content and messages_text: + return f""" +{summary_content} + + + +{messages_text} +""" + elif summary_content: + return f""" +{summary_content} +""" + elif messages_text: + return messages_text + else: + return "" + + def _format_messages(messages: list[models.Message]) -> str: """ Format a list of messages into a string by concatenating their content and