Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
bf4e075
Refactor agent initialization and fix permission flow
Rikul Apr 2, 2026
83dabdf
feat: Add comprehensive command system with /model command
Rikul Apr 22, 2026
4af626f
Merge main into dev: Accept main's refactored agent architecture
Rikul Apr 22, 2026
a32d64e
remove unused file
Rikul Apr 26, 2026
ee6e8a9
refactor: remove unused
Rikul Apr 26, 2026
9791621
refactor: remove unused
Rikul Apr 26, 2026
9abc579
reset dev branch
Rikul Apr 26, 2026
4a609d4
refactor(agents): session isolation in agent_loop + bump max_iteratio…
Rikul Apr 26, 2026
2e2bbe8
feat: added restart.sh to restart background agent, modified run.sh t…
Rikul Apr 26, 2026
504dbea
fix: add user msg to history before agent loop (in case it crashes), …
Rikul Apr 29, 2026
770cac5
updated sys_instructions.md
Rikul Apr 29, 2026
46f30ee
feat: added CalculatorTool with add, subtract, multiply, divide, expo…
Rikul Apr 30, 2026
e982a6e
fix: restart.sh forks/detaches itself before killing and restarting p…
Rikul Apr 30, 2026
38062a0
feat: added HackerNewsTool
Rikul Apr 30, 2026
be27ab0
updated sys_instructions.md
Rikul Apr 30, 2026
877588b
feat: Added WebSearch* tools for text,images,video,news and books search
Rikul May 1, 2026
0bb718c
feat: added helper_agent.py for future use
Rikul May 1, 2026
fe4fa02
refactor: added migration to rename history.db to app.db
Rikul May 1, 2026
57c9360
chore: removed unused imports
Rikul May 2, 2026
258b72a
feat: add scheduled tasks system with interval-based execution
Rikul May 3, 2026
cb2732a
feat: added next_run, delivery_channel, run_count to tasks
Rikul May 4, 2026
af1c542
fix: added _serialize_assistant_msg for asst msg. fixes deepseek v4 r…
Rikul May 4, 2026
5ef9816
updated sys_instructions.md
Rikul May 4, 2026
e2fc8d1
fix: telegram delivery was missing allow_from/chat_id metadata.
Rikul May 4, 2026
e56008a
refactor: replace enable/disable task tools with update_scheduled_tas…
Rikul May 4, 2026
8f8daa5
fix: tool spec errors
Rikul May 4, 2026
51ba6d7
fix: failing tests are updated
Rikul May 4, 2026
5f0bfd1
fix: added missing enabled to tool call specs
Rikul May 4, 2026
0e9fd10
fix: deepseek v4 reasoning_content bug
Rikul May 4, 2026
1700e28
chore: updated README
Rikul May 4, 2026
1d61ac9
feat: added dynamic vars to sys prompt like datetime, CWD, TZ etc.
Rikul May 5, 2026
892fd13
refactor: trying different sys prompt prose style
Rikul May 5, 2026
8e8a72c
refactor: restructure app/ into core/, cli/, channels/, infra/ packages
Rikul May 5, 2026
cc2ceab
fix: updated sys instru to ask confirmation before destructive actions.
Rikul May 5, 2026
3295981
fix: error while importing module named 'app.core.message'
Rikul May 6, 2026
c16abdd
refactor: move system context loading to agent_loop.
Rikul May 6, 2026
74f3efc
refactor: consolidate agent loop into Agent base class
Rikul May 6, 2026
723b0b2
refactor: add truncated intermediate messages for better context
Rikul May 7, 2026
e2cad8e
feat: Docker support with env var config
Rikul May 8, 2026
f699b4d
increased msg history len to 1000
Rikul May 8, 2026
3490794
feat: added get_city_state and get_datetime tools
Rikul May 8, 2026
c7662f7
fix: stop test_agent.py from writing to the real DB
Rikul May 8, 2026
255ffea
feat: add Discord channel with per-channel MQ isolation
Rikul May 13, 2026
a0a0e7e
refactor: removed compress turn function, messages only keep user/fin…
Rikul May 13, 2026
13261d9
fix: added ALLOW_FROM for Discord
Rikul May 13, 2026
12fa484
refactor: updated sys_instructions.md
Rikul May 13, 2026
5951cff
refactor: removed dup logging line
Rikul May 13, 2026
6fe1a0a
small text change
Rikul May 13, 2026
1c7d544
fix: fixed tests adding to real db
Rikul May 13, 2026
59c3b1b
chore: add uv.lock, fix line endings, update Dockerfile and README fo…
Rikul May 14, 2026
b13ccb9
fix: dockerfile bug
Rikul May 14, 2026
9602632
feat: added a review-pr skill
Rikul May 17, 2026
13d7296
feat: unified command registry for Telegram and Discord
Rikul May 18, 2026
6765105
fix / refactor: use send_message instead of channel-native reply
Rikul May 18, 2026
508ab3b
bumping the default model from →
Rikul May 18, 2026
34bcbba
feat: slash commands with args, /model, and error surfacing to users
Rikul May 18, 2026
f703fef
fix: missing f str
Rikul May 18, 2026
2540806
refactor: runtime settings singleton for mutable config
Rikul May 23, 2026
9d062ef
Refactor: move system prompt loading into agent.py, read model from r…
Rikul May 23, 2026
cd41e49
refactor/fix: removed old db stuff and fix a test
Rikul May 23, 2026
1fcbbad
Add conversation management with persistent named conversations
claude May 23, 2026
a1dee4a
Address PR review: FK enforcement, index, typing, lint fixes
claude May 23, 2026
4963847
Potential fix for pull request finding
Rikul May 23, 2026
48a3d51
Potential fix for pull request finding
Rikul May 23, 2026
65e3b41
Potential fix for pull request finding
Rikul May 23, 2026
9cdb489
Add idx_messages_conversation_id index in MessageHistory._ensure_db()
claude May 23, 2026
be8fc66
Fix load_messages() to return most recent messages, not oldest
claude May 23, 2026
5bf484e
fix: use persisted name after rename to keep runtime and response con…
claude May 23, 2026
e43201a
refactor:
Rikul May 24, 2026
b009d47
refactor: renamed cmds so they are easier to write; updated CLAUDE.md…
Rikul May 24, 2026
9522cb9
feat: added helper_agent tool, make run_tool async
Rikul May 26, 2026
e56f46e
fix: prevent scheduled task timing drift
Rikul May 27, 2026
e31efd4
fix: skip missed scheduled task runs instead of catching up
Rikul May 27, 2026
f978f4d
Merge pull request #6 from Rikul/fix/scheduled-task-timing-drift
Rikul May 31, 2026
fc081d9
feat: FastHTML web channel with chat UI (#9)
Rikul Jun 2, 2026
e177920
fix: /static routes fixed now
Rikul Jun 2, 2026
c73b0dd
merged from main
Rikul Jun 8, 2026
5d2044b
chore: removed some dead code
Rikul Jun 15, 2026
e737552
fix: failing test due to change
Rikul Jun 15, 2026
5700500
feat: Added tracing with command line --trace and --tracedir flags. A…
Rikul Jun 15, 2026
62f6202
fix: agent loop robustness and reasoning content extraction
Rikul Jun 16, 2026
08c0c63
Add support for file/image attachments in user messages (data URL enc…
Rikul Jun 16, 2026
23da9b5
fix: history adds [attachment] placeholder to messages
Rikul Jun 17, 2026
4d2c49b
Add file attachment support to web channel (#14)
Rikul Jun 17, 2026
434cc78
refactor: write_trace reads tracedir/model from runtime; attachment p…
Rikul Jun 18, 2026
817c824
fix: close leaked sqlite connections in scheduled_tasks, message_hist…
Rikul Jun 25, 2026
a620045
fix: enable WAL mode + busy_timeout for shared app.db to prevent lock…
claude Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The shared loop lives in `Agent._loop()` (`app/core/agent.py`). Subclasses overr
| `_on_no_choices` | raise | exponential backoff | raise |
| `_should_stop` | — | `channel.has_stopped` | — |

Tool calls within a single LLM turn are dispatched in parallel via `asyncio.gather`. After each turn, the full message chain (assistant tool-call message + tool results + final response) is saved to `self.messages` with tool results truncated to `TOOL_RESULT_HISTORY_LIMIT` chars to keep context lean. `MessageHistory` (SQLite) stores only user + final assistant text for cross-session persistence.
Tool calls within a single LLM turn are dispatched in parallel via `asyncio.gather`. After each turn, the full message chain (assistant tool-call message + tool results + final response) is saved to `self.messages` at full length. `MessageHistory` (SQLite) stores only user + final assistant text for cross-session persistence.

### Tool System

Expand Down Expand Up @@ -104,7 +104,7 @@ On startup, `load_system_context()` (`app/infra/startup.py`) loads `app/core/sys

**Channel types** are defined in `ChannelType` enum (`app/channels/channel.py`): `CLI`, `TELEGRAM`, `DISCORD`, `WEB`. Each channel implements the `Channel` ABC and owns a `MessageQueue` instance. `bg_server.py` wires up enabled channels — each gets its own `MessageQueue`, `BackgroundAgent`, and set of coroutines (`run_polling`, `process_incoming`, `process_outgoing`) gathered into the event loop.

**WebChannel** (`app/channels/web_channel.py`) uses `python-fasthtml` + uvicorn to serve both a browser chat UI (`GET /`) and a JSON WebSocket endpoint (`WS /ws`) on the same port. Multiple concurrent browser tabs are supported — each connection gets a UUID tracked in `_connections`. A per-connection `asyncio.Lock` in `_send_locks` serializes writes to each WebSocket. The channel also exposes `GET /api/conversations`, `GET /api/messages` (scoped to web channel only), and `GET /api/status` REST endpoints. Only `/whoami` is handled inline (it needs the per-connection client ID); all other slash commands — including `/help`, `/status`, `/stop` — are forwarded to `BackgroundAgent`'s `CommandRegistry` via the message queue with `is_command=True` in metadata so `send_message()` emits `{"type":"system"}` responses, enabling the sidebar to refresh after conversation-mutating commands.
**WebChannel** (`app/channels/web_channel.py`) uses `python-fasthtml` + uvicorn to serve both a browser chat UI (`GET /`) and a JSON WebSocket endpoint (`WS /ws`) on the same port. Multiple concurrent browser tabs are supported — each connection gets a UUID tracked in `_connections`. A per-connection `asyncio.Lock` in `_send_locks` serializes writes to each WebSocket. The channel also exposes `GET /api/conversations`, `GET /api/messages` (scoped to web channel only), `GET /api/status`, and `POST /api/upload` REST endpoints. `POST /api/upload` accepts multipart `files` (one or more), writes each under `$ANOTHERBOT_HOME/uploads` with a UUID prefix, and returns the stored basenames; the browser (paperclip button next to the input) sends those basenames back in the WebSocket `message` frame's `files` field. `_resolve_upload_paths()` joins each basename to the upload dir (basename-only, so client paths can't traverse out) and the resolved absolute paths reach the agent via `metadata["files"]`, which `Agent._build_user_message()` encodes as image/file parts. Only `/whoami` is handled inline (it needs the per-connection client ID); all other slash commands — including `/help`, `/status`, `/stop` — are forwarded to `BackgroundAgent`'s `CommandRegistry` via the message queue with `is_command=True` in metadata so `send_message()` emits `{"type":"system"}` responses, enabling the sidebar to refresh after conversation-mutating commands.

**Slash commands** are handled entirely by `BackgroundAgent`. Channel handlers (`command_handler` in Telegram, `on_message` in Discord) intercept only `/whoami` (resolved inline using the platform user object) and enqueue everything else as a plain `IncomingMessage`. `BackgroundAgent.process_incoming()` detects the leading `/` and dispatches via its own `CommandRegistry`. That registry owns the full command set: `/model`, `/status`, `/stop`, `/help`, `/list`, `/new`, `/load`, `/fork`, `/rename`, `/export`.

Expand Down
Empty file modified app/__init__.py
100755 → 100644
Empty file.
21 changes: 19 additions & 2 deletions app/channels/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ async def model_cmd(args: str = "") -> str:
return f"Model set to: {args.strip()}"


async def trace_cmd(args: str = "") -> str:
arg = args.strip().lower()
tracedir = runtime.get("tracedir")
if arg == "on":
runtime.set("trace", True)
return f"Tracing on. Writing to {tracedir}"
elif arg == "off":
runtime.set("trace", False)
return "Tracing off."
else:
state = runtime.get("trace", False)
return f"Tracing is {'on' if state else 'off'}. Dir: {tracedir}"


def make_status_cmd(channel_str: str = "") -> CommandHandler:
async def _status(args: str = "") -> str:
uptime = datetime.now() - _STARTUP_TIME
Expand All @@ -81,11 +95,15 @@ async def _status(args: str = "") -> str:
else:
conv_id = runtime.get("conversation_id", "—")
conv_name = runtime.get("conversation_name", "—")
tracing = runtime.get("trace", False)
last_trace = runtime.get("last_trace")
trace_line = f"on ({last_trace})" if (tracing and last_trace) else ("on" if tracing else "off")
return (
f"Bot status:\n"
f" Model: {model}\n"
f" Uptime: {hours}h {minutes}m {seconds}s\n"
f" Conversation: [{conv_id}] {conv_name}"
f" Conversation: [{conv_id}] {conv_name}\n"
f" Tracing: {trace_line}"
)
return _status

Expand Down Expand Up @@ -225,7 +243,6 @@ async def _mcp(args: str = "") -> str:
desc = fn.get("description", "")
lines.append(f" {bare}" + (f" — {desc}" if desc else ""))
return "\n".join(lines)
return "\n".join(lines)
else:
specs = mcp_manager.get_tool_specs()
if not specs:
Expand Down
79 changes: 79 additions & 0 deletions app/channels/static/web_channel.css
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,82 @@ body {
#send-btn:hover { background: var(--accent-h); }
#send-btn:active { transform: scale(.97); }
#send-btn:disabled { background: var(--border); cursor: not-allowed; }

/* Hide the file input without display:none — keeps it in the layout/event
tree so its `change` event fires reliably (Edge) when opened via the label. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

/* ---- attach button (a <label for="file-input">) ---- */
#attach-btn {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 42px;
width: 42px;
padding: 0;
margin: 0;
line-height: 0; /* kill inline baseline gap (Edge alignment) */
flex-shrink: 0;
user-select: none;
-webkit-user-select: none;
transition: border-color .15s, color .15s, background .15s;
}
#attach-btn:hover { border-color: var(--accent); color: var(--accent); }
#attach-btn:active { transform: scale(.97); }
#attach-btn svg { display: block; width: 20px; height: 20px; }

/* ---- attachment chips ---- */
#attachments {
display: none;
flex-wrap: wrap;
gap: 8px;
padding: 10px 20px 0;
background: var(--surface);
flex-shrink: 0;
}
#attachments.has-files { display: flex; }

.attach-chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 240px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 8px 5px 10px;
font-size: 0.8rem;
}
.attach-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.attach-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 2px;
transition: color .12s;
}
.attach-remove:hover { color: #ef4444; }
113 changes: 108 additions & 5 deletions app/channels/static/web_channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
const toggleBtn = document.getElementById('sidebar-toggle');
const convListEl = document.getElementById('conv-list');
const newConvBtn = document.getElementById('new-conv-btn');
const fileInput = document.getElementById('file-input');
const attachEl = document.getElementById('attachments');

// Files staged for the next send (cleared after a successful upload).
let selectedFiles = [];

// ---- theme ----
const savedTheme = localStorage.getItem('ab-theme') || 'dark';
Expand Down Expand Up @@ -141,7 +146,7 @@
ws.onopen = () => {
reconnectDelay = 1000;
setStatus('connected', 'Connected');
sendBtn.disabled = false;
refreshSendState();
clearMessages(); // always reset DOM before reloading — prevents duplicates on reconnect
loadConversations(true);
};
Expand Down Expand Up @@ -213,14 +218,105 @@
}
function scrollBottom() { scrollEl.scrollTop = scrollEl.scrollHeight; }

// ---- attachments ----
function renderAttachments() {
attachEl.innerHTML = '';
attachEl.classList.toggle('has-files', selectedFiles.length > 0);
selectedFiles.forEach((file, idx) => {
const chip = document.createElement('div');
chip.className = 'attach-chip';
const label = document.createElement('span');
label.className = 'attach-name';
label.textContent = file.name;
label.title = `${file.name} (${fmtSize(file.size)})`;
const remove = document.createElement('button');
remove.className = 'attach-remove';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Remove attachment';
remove.addEventListener('click', () => {
selectedFiles.splice(idx, 1);
renderAttachments();
refreshSendState();
});
chip.appendChild(label);
chip.appendChild(remove);
attachEl.appendChild(chip);
});
}

function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}

function refreshSendState() {
const connected = ws && ws.readyState === WebSocket.OPEN;
const hasInput = inputEl.value.trim().length > 0 || selectedFiles.length > 0;
sendBtn.disabled = !connected || !hasInput;
}

// The attach control is a <label for="file-input">, so clicking it opens
// the picker natively — no JS click needed (and avoids a double-open).
// Copy the FileList into a real array before clearing .value: Edge can
// invalidate the live FileList once .value is reset mid-handler.
fileInput.addEventListener('change', () => {
const picked = Array.from(fileInput.files || []);
for (const file of picked) selectedFiles.push(file);
fileInput.value = ''; // allow re-selecting the same file later
renderAttachments();
refreshSendState();
});

async function uploadFiles(files) {
const form = new FormData();
files.forEach(f => form.append('files', f, f.name));
const res = await fetch('/api/upload', { method: 'POST', body: form });
if (!res.ok) {
let detail = '';
try { detail = (await res.json()).error || ''; } catch (e) { /* ignore */ }
throw new Error(detail || `Upload failed (${res.status})`);
}
const { files: saved } = await res.json();
return saved; // [{path, name}, ...]
}

// ---- send ----
function send() {
async function send() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
appendMessage(text, 'user');
ws.send(JSON.stringify({ type: 'message', content: text }));
const files = selectedFiles.slice();
if (sendBtn.disabled) return;

let uploaded = [];
if (files.length) {
sendBtn.disabled = true;
try {
uploaded = await uploadFiles(files);
} catch (e) {
appendMessage('Attachment upload failed: ' + e.message, 'system');
refreshSendState();
return;
}
}

const payload = { type: 'message', content: text };
if (uploaded.length) payload.files = uploaded.map(f => f.path);
try {
ws.send(JSON.stringify(payload));
} catch (err) {
appendMessage('Send failed: disconnected (please retry).', 'system');
refreshSendState();
return;
}

appendMessage(renderUserContent(text, files), 'user');
selectedFiles = [];
renderAttachments();
inputEl.value = '';
inputEl.style.height = '42px';
refreshSendState();

if (text === '/new') {
clearMessages();
} else if (/^\/load\s+\d+/.test(text)) {
Expand All @@ -232,13 +328,20 @@
}
}

function renderUserContent(text, files) {
if (!files.length) return text;
const tags = files.map(f => `📎 ${f.name}`).join(' ');
return text ? `${text}\n\n${tags}` : tags;
}

sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
refreshSendState();
});

// sidebar pre-populate (messages already loaded via ws.onopen → loadConversations(true))
Expand Down
Loading
Loading