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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,12 @@
You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text.
</discord-sidecar-channel-behavior>

<discord-sidecar-media>
Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `<discord-media>` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence.

To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply.
</discord-sidecar-media>

<discord-audience>
You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message.

Expand Down
41 changes: 23 additions & 18 deletions module/channel-adapters/discord/scripts/bridge.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,26 +9,16 @@
record_channel_turn_productivity,
resolve_active_one_shot_turn_command,
)
from channel_message.inbound_media import prompt_for_message_with_media
from channel_message.outbound_reply import (
send_reply,
split_reply_into_text_and_attachments,
)
from harness_turn import run_one_turn

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN"


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def log(agent_name: str, message: str) -> None:
print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True)

Expand DownExpand Up@@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message):
f"{active_harness_name}."
)
return
prompt = await prompt_for_message_with_media(
message,
self.state_directory,
lambda note: log(self.agent_name, note),
)
if not prompt.strip():
log(
self.agent_name,
f"message {message.id} carried nothing this bridge can render",
)
return
reply, failure = await asyncio.to_thread(
run_one_turn,
one_shot_turn_command,
self.workspace_directory,
self.state_directory,
message.clean_content,
prompt,
self.daily_session_rotation,
)
record_channel_turn_productivity(
Expand All@@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message):
f"{self.agent_name} could not answer that turn. Its log has the detail."
)
return
for sendable_message in split_into_sendable_messages(reply):
await message.channel.send(sendable_message)
split = split_reply_into_text_and_attachments(reply, self.workspace_directory)
for refused_path in split.refused_paths:
log(self.agent_name, f"refused to attach {refused_path}")
await send_reply(
message.channel, split, lambda note: log(self.agent_name, note)
)


def parse_arguments() -> argparse.Namespace:
Expand Down
Empty file.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
import dataclasses
import os

import discord
from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name

ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024
MEDIA_BLOCK_OPENING_TAG = "<discord-media>"
MEDIA_BLOCK_CLOSING_TAG = "</discord-media>"
DOWNLOAD_FAILED_REASON = "download failed"


@dataclasses.dataclass(frozen=True)
class AttachmentIntake:
attachment: object
destination_path: str | None
description_line: str


def human_readable_size(byte_count: int) -> str:
if byte_count >= 1024 * 1024:
return f"{byte_count / 1024 / 1024:.1f} MB"
if byte_count >= 1024:
return f"{byte_count / 1024:.1f} KB"
return f"{byte_count} B"


def oversized_reason() -> str:
return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit"


def measure_attachment(attachment) -> str:
described_type = attachment.content_type or "unknown type"
return (
f"{attachment.filename} "
f"({described_type}, {human_readable_size(attachment.size)})"
)


def describe_saved_attachment(attachment, destination_path: str) -> str:
return f"attachment {measure_attachment(attachment)} saved at {destination_path}"


def describe_unsaved_attachment(attachment, reason: str) -> str:
return (
f"attachment {measure_attachment(attachment)} not saved "
f"({reason}), at {attachment.url}"
)


def plan_attachment_intake(
attachments, destination_directory: str
) -> list[AttachmentIntake]:
planned = []
for position, attachment in enumerate(attachments):
if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES:
planned.append(
AttachmentIntake(
attachment,
None,
describe_unsaved_attachment(attachment, oversized_reason()),
)
)
continue
destination_path = os.path.join(
destination_directory, f"{position}-{safe_file_name(attachment.filename)}"
)
planned.append(
AttachmentIntake(
attachment,
destination_path,
describe_saved_attachment(attachment, destination_path),
)
)
return planned


def describe_stickers(stickers) -> list[str]:
return [f"sticker {sticker.name}" for sticker in stickers]


def prompt_for_message(text: str, description_lines: list[str]) -> str:
if not description_lines:
return text
media_block = "\n".join(
[MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG]
)
if not text.strip():
return media_block
return f"{text}\n\n{media_block}"


async def save_attachments(attachments, state_directory, message_identifier, report):
if not attachments:
return []
destination_directory = prepare_inbox_for_message(
state_directory, message_identifier
)
description_lines = []
for intake in plan_attachment_intake(attachments, destination_directory):
if intake.destination_path is None:
description_lines.append(intake.description_line)
continue
try:
await intake.attachment.save(intake.destination_path)
except (discord.DiscordException, OSError) as saving_failure:
report(f"could not save {intake.attachment.filename}: {saving_failure}")
description_lines.append(
describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON)
)
continue
description_lines.append(intake.description_line)
return description_lines


async def prompt_for_message_with_media(message, state_directory, report) -> str:
description_lines = await save_attachments(
message.attachments, state_directory, str(message.id), report
)
description_lines.extend(describe_stickers(message.stickers))
return prompt_for_message(message.clean_content, description_lines)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
import os
import re
import shutil
import time

INBOX_DIRECTORY_NAME = "inbox"
INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60
INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024
UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]")
LONGEST_KEPT_FILE_NAME = 96
FALLBACK_FILE_NAME = "attachment"


def inbox_directory(state_directory: str) -> str:
return os.path.join(state_directory, INBOX_DIRECTORY_NAME)


def message_inbox_directory(state_directory: str, message_identifier: str) -> str:
return os.path.join(
inbox_directory(state_directory), safe_file_name(message_identifier)
)


def safe_file_name(file_name: str) -> str:
sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".")
if len(sanitized) > LONGEST_KEPT_FILE_NAME:
stem, extension = os.path.splitext(sanitized)
sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension
return sanitized or FALLBACK_FILE_NAME


def message_directory_size(directory_path: str) -> int:
total_size = 0
for visited_directory, _, file_names in os.walk(directory_path):
for file_name in file_names:
try:
total_size += os.path.getsize(
os.path.join(visited_directory, file_name)
)
except OSError:
continue
return total_size


def message_directories_oldest_first(state_directory: str) -> list:
try:
with os.scandir(inbox_directory(state_directory)) as entries:
message_directories = [entry for entry in entries if entry.is_dir()]
except OSError:
return []
return sorted(message_directories, key=lambda entry: entry.stat().st_mtime)


def evict_until_the_inbox_fits(message_directories) -> None:
measured = [
(entry, message_directory_size(entry.path)) for entry in message_directories
]
kept_size = sum(size for _, size in measured)
for entry, size in measured:
if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES:
return
shutil.rmtree(entry.path, ignore_errors=True)
kept_size -= size


def prune_expired_inbox(state_directory: str, now: float) -> None:
cutoff = now - INBOX_RETENTION_SECONDS
surviving_directories = []
for entry in message_directories_oldest_first(state_directory):
if entry.stat().st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
else:
surviving_directories.append(entry)
evict_until_the_inbox_fits(surviving_directories)


def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str:
prune_expired_inbox(state_directory, time.time())
destination_directory = message_inbox_directory(state_directory, message_identifier)
os.makedirs(destination_directory, exist_ok=True)
return destination_directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import dataclasses
import os

import discord

DISCORD_MESSAGE_CHARACTER_LIMIT = 2000
DISCORD_ATTACHMENT_COUNT_LIMIT = 10
ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class ReplyAttachmentSplit:
text: str
attachment_paths: list[str]
refused_paths: list[str]


def split_into_sendable_messages(reply: str) -> list[str]:
remaining = reply
messages = []
while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT:
split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT)
if split_position <= 0:
split_position = DISCORD_MESSAGE_CHARACTER_LIMIT
messages.append(remaining[:split_position])
remaining = remaining[split_position:].lstrip("\n")
if remaining:
messages.append(remaining)
return messages


def path_lies_inside_directory(candidate_path: str, directory: str) -> bool:
resolved_directory = os.path.realpath(directory)
resolved_candidate = os.path.realpath(candidate_path)
if resolved_candidate == resolved_directory:
return False
return (
os.path.commonpath([resolved_directory, resolved_candidate])
== resolved_directory
)


def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool:
candidate_path = line.strip()
if not os.path.isabs(candidate_path):
return False
if not path_lies_inside_directory(candidate_path, workspace_directory):
return False
return os.path.isfile(candidate_path)


def attachment_is_small_enough_to_upload(attachment_path: str) -> bool:
return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES


def split_reply_into_text_and_attachments(
reply: str, workspace_directory: str
) -> ReplyAttachmentSplit:
kept_lines = []
attachment_paths = []
refused_paths = []
for line in reply.split("\n"):
if not line_names_a_workspace_file(line, workspace_directory):
kept_lines.append(line)
continue
attachment_path = line.strip()
if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT:
refused_paths.append(attachment_path)
elif not attachment_is_small_enough_to_upload(attachment_path):
refused_paths.append(attachment_path)
else:
attachment_paths.append(attachment_path)
return ReplyAttachmentSplit(
"\n".join(kept_lines).strip(), attachment_paths, refused_paths
)


async def send_leading_message(channel, leading_message, attachment_paths, report):
if not attachment_paths:
await channel.send(leading_message)
return
try:
await channel.send(
leading_message, files=[discord.File(path) for path in attachment_paths]
)
except discord.HTTPException as sending_failure:
report(
f"discord refused {len(attachment_paths)} attachments: {sending_failure}"
)
if leading_message is not None:
await channel.send(leading_message)


async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None:
sendable_messages = split_into_sendable_messages(split.text)
leading_message = sendable_messages[0] if sendable_messages else None
if leading_message is None and not split.attachment_paths:
return
await send_leading_message(channel, leading_message, split.attachment_paths, report)
for sendable_message in sendable_messages[1:]:
await channel.send(sendable_message)
Loading