Skip to content
Draft
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
6d327d2
feat: Discord-like group chat - channels, threads, @mention routing
Josh-XT Feb 10, 2026
dcf2839
Add channel categories and update channel endpoint
Josh-XT Feb 10, 2026
27f89c9
Add emoji reactions system for messages
Josh-XT Feb 10, 2026
db6b935
Updates
Josh-XT Feb 10, 2026
6a1f84e
more improvements for group chat
Josh-XT Feb 10, 2026
11b6aae
lint
Josh-XT Feb 10, 2026
6fb0ed6
faster
Josh-XT Feb 10, 2026
58919ba
Updates
Josh-XT Feb 11, 2026
edc2c25
lint
Josh-XT Feb 11, 2026
99c54da
more Updates
Josh-XT Feb 11, 2026
d8daa2f
add pinning and improve response cache
Josh-XT Feb 11, 2026
8fa2822
lint
Josh-XT Feb 11, 2026
b0d0666
bug fixes and featuers
Josh-XT Feb 11, 2026
397c2ba
lint
Josh-XT Feb 11, 2026
fd60466
more features and bugfixes
Josh-XT Feb 12, 2026
048293c
lint
Josh-XT Feb 12, 2026
ad38bc2
add types
Josh-XT Feb 12, 2026
a3c2ab7
Merge branch 'main' into group-chats
Josh-XT Feb 12, 2026
2e4bb8f
add preferences
Josh-XT Feb 12, 2026
489da46
more bugfixes
Josh-XT Feb 12, 2026
bbfd0a9
lint
Josh-XT Feb 12, 2026
3c6fd19
add imports
Josh-XT Feb 12, 2026
61ab987
add dm types
Josh-XT Feb 12, 2026
6e14d5d
add last read
Josh-XT Feb 12, 2026
f912849
more bugfixes
Josh-XT Feb 12, 2026
2242083
add sorting
Josh-XT Feb 12, 2026
ad68fbc
add thread deletion logic
Josh-XT Feb 12, 2026
72b50ce
fix codeql warnings
Josh-XT Feb 12, 2026
fbd5ee7
fix codeql warning
Josh-XT Feb 12, 2026
58c1289
lint
Josh-XT Feb 12, 2026
2b16501
fix warning
Josh-XT Feb 12, 2026
33207b8
use os.path.basename for CodeQL-recognized path sanitization
Josh-XT Feb 12, 2026
7c6a518
fix: add _sanitize_filename helper and use os.path.abspath+normpath f…
Josh-XT Feb 12, 2026
778b74c
lint
Josh-XT Feb 12, 2026
613a9ff
perf: batch unread counts, EXISTS subquery, combine conversation look…
Josh-XT Feb 12, 2026
d871e3f
lint
Josh-XT Feb 12, 2026
1283ade
fix: block agent responses in user-to-user DMs and DM threads
Josh-XT Feb 12, 2026
c593f06
Fix Fernet decryption leak: return empty string on failure instead of…
Josh-XT Feb 12, 2026
1cd56db
lint
Josh-XT Feb 12, 2026
3af2d93
Increase WebSocket initial load limit to 1000 messages
Josh-XT Feb 12, 2026
6230a88
clean up
Josh-XT Feb 12, 2026
e93b172
Fix get_conversation() returning oldest messages instead of newest
Josh-XT Feb 12, 2026
e106f4b
Add pagination metadata to conversation endpoint
Josh-XT Feb 13, 2026
dacb983
Allow spaces in filename sanitization for /outputs/ endpoint
Josh-XT Feb 13, 2026
ea80b1b
Allow spaces in ensure_safe_path component regex for filenames with s…
Josh-XT Feb 13, 2026
11db225
Fix thread member list: inherit parent channel participants dynamically
Josh-XT Feb 13, 2026
eb3ead7
lint
Josh-XT Feb 13, 2026
0d70a0c
Add server-side ffmpeg video thumbnails and proper cache headers for …
Josh-XT Feb 13, 2026
ddd3028
Fix thumbnail 500 errors for audio-only .webm files
Josh-XT Feb 13, 2026
bd54385
Fix unread divider never clearing — timestamp format mismatch
Josh-XT Feb 13, 2026
0ca8ed2
perf: reduce WebSocket initial_data load and defer notification mark-…
Josh-XT Feb 13, 2026
2a2572f
fix: delete_message_by_id now prefers conversation_id lookup
Josh-XT Feb 13, 2026
d16a80b
lint
Josh-XT Feb 13, 2026
c7218f2
Add thread lock/close, mention/reply notifications, and locked field …
Josh-XT Feb 14, 2026
478b294
lint
Josh-XT Feb 14, 2026
88f4ff9
lint
Josh-XT Feb 14, 2026
5284ec7
lint
Josh-XT Feb 14, 2026
3f9f556
fix replyto handling
Josh-XT Feb 14, 2026
d56939b
fix regex
Josh-XT Feb 14, 2026
d0e157e
speed
Josh-XT Feb 14, 2026
38a9ed5
performance optimizations
Josh-XT Feb 14, 2026
f7fb615
performance updates
Josh-XT Feb 14, 2026
5a11d5e
lint
Josh-XT Feb 14, 2026
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
2,423 changes: 2,316 additions & 107 deletions agixt/Conversations.py

Large diffs are not rendered by default.

1,511 changes: 1,331 additions & 180 deletions agixt/DB.py

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions agixt/Interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2616,6 +2616,31 @@ async def run_stream(
if file_matches:
file_context = f"Uploaded files: {', '.join(file_matches)}"

# Auto-discover existing files in the conversation workspace
try:
workspace_dir = (
f"{self.agent.working_directory}/{c.get_conversation_id()}"
)
if os.path.isdir(workspace_dir):
existing_files = []
for root, dirs, files in os.walk(workspace_dir):
for f in files:
rel_path = os.path.relpath(
os.path.join(root, f), workspace_dir
)
existing_files.append(rel_path)
if existing_files:
existing_context = (
f"Existing workspace files: {', '.join(existing_files)}"
)
if file_context:
file_context = f"{file_context}\n{existing_context}"
else:
file_context = existing_context
has_uploaded_files = True
except Exception:
pass

# Do intelligent command selection
try:
selected_commands = await self.select_commands_for_task(
Expand Down
265 changes: 206 additions & 59 deletions agixt/MagicalAuth.py

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions agixt/Models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class UserResponse(BaseModel):
last_name: str
role: str
role_id: int
avatar_url: Optional[str] = None


class CompanyResponse(BaseModel):
Expand All @@ -29,6 +30,7 @@ class CompanyResponse(BaseModel):
zip_code: Optional[str] = None
country: Optional[str] = None
notes: Optional[str] = None
icon_url: Optional[str] = None
users: List[UserResponse]
children: List["CompanyResponse"] = []

Expand Down Expand Up @@ -129,6 +131,7 @@ class Invitation(BaseModel):
class UserInfo(BaseModel):
first_name: str
last_name: str
username: str


class Detail(BaseModel):
Expand Down Expand Up @@ -798,6 +801,9 @@ class ConversationDetailResponse(BaseModel):

class ConversationHistoryResponse(BaseModel):
conversation_history: List[Dict[str, Any]]
total: Optional[int] = None
page: Optional[int] = None
limit: Optional[int] = None


class NewConversationHistoryResponse(BaseModel):
Expand All @@ -813,6 +819,23 @@ class MessageIdResponse(BaseModel):
message: str # Contains the message ID


class AddReactionModel(BaseModel):
emoji: str


class ReactionResponse(BaseModel):
id: str
emoji: str
user_id: str
user_email: Optional[str] = None
user_first_name: Optional[str] = None
created_at: Optional[str] = None


class MessageReactionsResponse(BaseModel):
reactions: List[ReactionResponse]


class ChatCompletionResponse(BaseModel):
id: str
object: str = "chat.completion"
Expand Down Expand Up @@ -912,6 +935,7 @@ class UpdateCompanyInput(BaseModel):
zip_code: Optional[str] = None
country: Optional[str] = None
notes: Optional[str] = None
icon_url: Optional[str] = None


# Wallet Models
Expand Down Expand Up @@ -1128,6 +1152,115 @@ class SharedConversationResponse(BaseModel):
include_workspace: bool


# Group Chat / Discord-like Models
class CreateGroupConversationModel(BaseModel):
"""Request model for creating a group conversation (channel) or thread"""

conversation_name: str
company_id: str
conversation_type: str = "group" # 'group', 'dm', or 'thread'
agent_names: Optional[List[str]] = []
parent_id: Optional[str] = None # For threads: the parent channel conversation ID
parent_message_id: Optional[str] = (
None # For threads: the message that spawned this thread
)
category: Optional[str] = (
None # Channel category for grouping (e.g., "Text Channels")
)
invite_only: bool = False # If True, only explicitly invited users can join


class AddParticipantModel(BaseModel):
"""Request model for adding a participant to a group conversation"""

user_id: Optional[str] = None # For user participants
agent_id: Optional[str] = None # For agent participants
participant_type: str = "user" # 'user' or 'agent'
role: str = "member" # 'owner', 'admin', 'member', 'observer'


class UpdateParticipantRoleModel(BaseModel):
"""Request model for updating a participant's role"""

role: str # 'owner', 'admin', 'member', 'observer'


class UpdateNotificationSettingsModel(BaseModel):
"""Request model for updating per-channel notification settings"""

notification_mode: str = "all" # 'all', 'mentions', 'none'


class NotificationSettingsResponse(BaseModel):
"""Response model for per-channel notification settings"""

notification_mode: str = "all"


class UpdateChannelModel(BaseModel):
"""Request model for updating a channel's properties"""

category: Optional[str] = None # Channel category for grouping
name: Optional[str] = None # Channel name
description: Optional[str] = None # Channel topic/description


class ParticipantResponse(BaseModel):
"""Response model for a conversation participant"""

id: str
participant_type: str
role: str
joined_at: Optional[str] = None
last_read_at: Optional[str] = None
status: str
user: Optional[Dict[str, Any]] = None
agent: Optional[Dict[str, Any]] = None


class GroupConversationResponse(BaseModel):
"""Response model for a group conversation"""

id: str
name: str
conversation_type: str
company_id: Optional[str] = None
parent_id: Optional[str] = None # For threads: parent channel ID
parent_message_id: Optional[str] = None # For threads: originating message ID
created_at: Optional[str] = None
updated_at: Optional[str] = None
has_notifications: bool = False
participant_count: int = 0
thread_count: int = 0 # Number of threads in this channel


class ThreadResponse(BaseModel):
"""Response model for a thread within a channel"""

id: str
name: str
parent_id: str
parent_message_id: Optional[str] = None
conversation_type: str = "thread"
created_at: Optional[str] = None
updated_at: Optional[str] = None
message_count: int = 0
last_message_at: Optional[str] = None
locked: bool = False


class ThreadListResponse(BaseModel):
"""Response model for list of threads in a channel"""

threads: List[ThreadResponse]


class GroupConversationListResponse(BaseModel):
"""Response model for list of group conversations"""

conversations: Dict[str, Dict[str, Any]]


# Scope and Custom Role Models
class ScopeResponse(BaseModel):
"""Response model for a scope"""
Expand Down
2 changes: 2 additions & 0 deletions agixt/ResponseCache.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class ResponseCacheManager:
], # Bulk command toggle
# Conversation mutations
"POST:/v1/conversation": ["conversation"],
"PUT:/v1/conversation": ["conversation"],
"PATCH:/v1/conversation": ["conversation"],
"DELETE:/v1/conversation": ["conversation"],
# Company mutations
"POST:/v1/company": ["company"],
Expand Down
23 changes: 3 additions & 20 deletions agixt/WhatsAppBotManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,7 @@ def __init__(
# Cache of phone numbers to AGiXT user IDs
self._user_id_cache: Dict[str, str] = {}

# Log initialization without exposing sensitive phone number data
masked_phone = (
"****" + (display_phone_number or phone_number_id or "")[-4:]
if (display_phone_number or phone_number_id)
else "unknown"
)
logger.info(
f"Initialized WhatsApp bot for company {company_name}, "
f"phone {masked_phone}"
)
logger.info(f"Initialized WhatsApp bot for company {company_name}")

def _get_headers(self):
"""Get authorization headers."""
Expand Down Expand Up @@ -230,9 +221,7 @@ async def _get_user_token(self, phone_number: str) -> Optional[str]:
try:
return impersonate_user(agixt_user_id)
except Exception as e:
# Log error without exposing sensitive phone number
masked_phone = "****" + phone_number[-4:] if phone_number else "unknown"
logger.error(f"Error impersonating user {masked_phone}: {type(e).__name__}")
logger.error(f"Error impersonating user: {type(e).__name__}")
return None

async def _get_available_agents(self) -> List[str]:
Expand Down Expand Up @@ -921,13 +910,7 @@ async def process_webhook(self, data: Dict):
# Find the bot for this phone number
bot = self.bots.get(phone_number_id)
if not bot:
# Log warning without exposing full phone number ID
masked_id = (
"****" + (phone_number_id or "")[-4:]
if phone_number_id
else "unknown"
)
logger.warning(f"No bot configured for phone {masked_id}")
logger.warning("No bot configured for incoming phone number")
continue

# Process messages
Expand Down
19 changes: 13 additions & 6 deletions agixt/Workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ def ensure_safe_path(
if component == "..":
raise ValueError("Path traversal detected")
# Validate each component matches safe pattern
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$", component):
# Allow alphanumeric, underscore, dot, hyphen, and space
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_.\- ]*$", component):
raise ValueError(f"Invalid path component: {component}")
# Create a new string to break taint chain
validated_components.append(str(component))
Expand Down Expand Up @@ -541,12 +542,15 @@ def sanitize_path_component(component: str, component_type: str) -> str:
raise ValueError(f"{component_type} too long")
return component

# Validate agent_id - use raw agent_id to match Agent.py behavior
# Validate agent_id and hash it to match Agent.py's workspace directory naming
validated_agent_id = sanitize_path_component(
self.validate_identifier(agent_id, "agent_id"), "agent_id"
)
# Use raw agent_id directly - Agent.py stores files using raw agent_id, not hashed
agent_folder = validated_agent_id
# Agent.py stores files under agent_{sha256(agent_id)[:16]} directories
import hashlib

agent_hash = hashlib.sha256(str(validated_agent_id).encode()).hexdigest()[:16]
agent_folder = f"agent_{agent_hash}"

filename = sanitize_path_component(self.validate_filename(filename), "filename")
conversation_id = (
Expand Down Expand Up @@ -581,8 +585,11 @@ def _get_object_path(
) -> str:
"""Get the object path in the storage backend with validation"""
agent_id = self.validate_identifier(agent_id, "agent_id")
# Use raw agent_id directly to match Agent.py behavior
agent_folder = agent_id
# Hash agent_id to match Agent.py's workspace directory naming
import hashlib

agent_hash = hashlib.sha256(str(agent_id).encode()).hexdigest()[:16]
agent_folder = f"agent_{agent_hash}"
filename = self.validate_filename(filename)

if conversation_id:
Expand Down
57 changes: 56 additions & 1 deletion agixt/XT.py
Original file line number Diff line number Diff line change
Expand Up @@ -3010,7 +3010,23 @@ async def _execute_chat_completions(self, prompt: ChatCompletions):
"total_tokens": 0,
},
}
# Include audio with transcription for display
audio_ext = (
file_name.rsplit(".", 1)[-1]
if "." in file_name
else "webm"
)
# Use the converted WAV file URL for the audio player (not the raw base64)
wav_filename = os.path.basename(wav_file)
audio_url = f"{self.outputs}/{conversation_id}/{wav_filename}"
# Only add transcription to the prompt (for the AI)
new_prompt += transcribed_audio
# Mark that we have voice audio to include in the display message
if not hasattr(self, "_voice_audio_display"):
self._voice_audio_display = []
self._voice_audio_display.append(
f'<audio controls><source src="{audio_url}" type="audio/wav"></audio>'
)
# Save the original user prompt before adding file info (for logging)
original_user_prompt = new_prompt.strip()
# Add file info to the prompt (for context to the agent)
Expand All @@ -3025,7 +3041,46 @@ async def _execute_chat_completions(self, prompt: ChatCompletions):
if log_user_input and not has_tool_result:
# Log the original user input, not the modified one with file names appended
# Don't log tool results as USER - they should be logged as TOOL subactivity
c.log_interaction(role="USER", message=original_user_prompt)
# Include voice audio player HTML if this was a voice recording
display_message = original_user_prompt
if hasattr(self, "_voice_audio_display") and self._voice_audio_display:
audio_html = "\n".join(self._voice_audio_display)
# Format: audio player(s) followed by transcription with a label
transcription_text = original_user_prompt.strip()
if transcription_text:
display_message = (
f"{audio_html}\n\n*Transcription:* {transcription_text}"
)
else:
display_message = audio_html
del self._voice_audio_display
# Include image markdown for uploaded image files so they display in conversation
if files:
image_extensions = {
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"ico",
}
image_markdown_parts = []
for file in files:
fname = file.get("file_name", "")
furl = file.get("file_url", "")
ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""
if ext in image_extensions and furl:
image_markdown_parts.append(f"![{fname}]({furl})")
if image_markdown_parts:
image_md = "\n".join(image_markdown_parts)
display_message = (
f"{display_message}\n\n{image_md}"
if display_message
else image_md
)
c.log_interaction(role="USER", message=display_message)
thinking_id = ""
if log_output:
thinking_id = c.get_thinking_id(agent_name=self.agent_name)
Expand Down
Loading
Loading