diff --git a/.archon/config.yaml b/.archon/config.yaml index beadf10287..7cff7bd6ad 100644 --- a/.archon/config.yaml +++ b/.archon/config.yaml @@ -1,5 +1,11 @@ worktree: baseBranch: dev +assistants: + claude: + settingSources: + - project + - user + docs: path: packages/docs-web/src/content/docs diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000000..eb82c48f41 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,72 @@ +# Dolt database (managed by Dolt, not git) +dolt/ + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +export-state/ +export-state.json + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000000..dbfe3631cf --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --claim +bd update --status done + +# Sync with Dolt remote +bd dolt push +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in Dolt database with version control and branching +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Dolt-native three-way merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000000..232b151111 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,54 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, bd will use .beads/issues.jsonl as the source of truth +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) +# actor: "" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# JSONL backup (periodic export for off-machine recovery) +# Auto-enabled when a git remote exists. Override explicitly: +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-exports +# git-push: false # Disable git push (export locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/hooks/applypatch-msg b/.beads/hooks/applypatch-msg new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/applypatch-msg @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/commit-msg b/.beads/hooks/commit-msg new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/commit-msg @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/h b/.beads/hooks/h new file mode 100755 index 0000000000..bf7c896408 --- /dev/null +++ b/.beads/hooks/h @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +[ "$HUSKY" = "2" ] && set -x +n=$(basename "$0") +s=$(dirname "$(dirname "$0")")/$n + +[ ! -f "$s" ] && exit 0 + +if [ -f "$HOME/.huskyrc" ]; then + echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh" +fi +i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" +[ -f "$i" ] && . "$i" + +[ "${HUSKY-}" = "0" ] && exit 0 + +export PATH="node_modules/.bin:$PATH" +sh -e "$s" "$@" +c=$? + +[ $c != 0 ] && echo "husky - $n script failed (code $c)" +[ $c = 127 ] && echo "husky - command not found in PATH=$PATH" +exit $c diff --git a/.beads/hooks/husky.sh b/.beads/hooks/husky.sh new file mode 100755 index 0000000000..f9d0637909 --- /dev/null +++ b/.beads/hooks/husky.sh @@ -0,0 +1,9 @@ +echo "husky - DEPRECATED + +Please remove the following two lines from $0: + +#!/usr/bin/env sh +. \"\$(dirname -- \"\$0\")/_/husky.sh\" + +They WILL FAIL in v10.0.0 +" \ No newline at end of file diff --git a/.beads/hooks/post-applypatch b/.beads/hooks/post-applypatch new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/post-applypatch @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout new file mode 100755 index 0000000000..813708c176 --- /dev/null +++ b/.beads/hooks/post-checkout @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" + +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-checkout "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-checkout "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/post-commit b/.beads/hooks/post-commit new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/post-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge new file mode 100755 index 0000000000..eda720ae76 --- /dev/null +++ b/.beads/hooks/post-merge @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" + +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-merge "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-merge "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-merge'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/post-rewrite b/.beads/hooks/post-rewrite new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/post-rewrite @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/pre-applypatch b/.beads/hooks/pre-applypatch new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/pre-applypatch @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/pre-auto-gc b/.beads/hooks/pre-auto-gc new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/pre-auto-gc @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit new file mode 100755 index 0000000000..0620b11441 --- /dev/null +++ b/.beads/hooks/pre-commit @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" + +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-commit "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-commit "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/pre-merge-commit b/.beads/hooks/pre-merge-commit new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/pre-merge-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push new file mode 100755 index 0000000000..7c1a4b8f71 --- /dev/null +++ b/.beads/hooks/pre-push @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" + +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-push "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-push "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-push'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/pre-rebase b/.beads/hooks/pre-rebase new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.beads/hooks/pre-rebase @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg new file mode 100755 index 0000000000..98cfb10bff --- /dev/null +++ b/.beads/hooks/prepare-commit-msg @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" + +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000000..89d57e90d3 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + "dolt_database": "Archon", + "project_id": "50332000-ce1c-4bf3-9536-8e3549753f25" +} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index bd3b092546..0d6e5792b1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,61 +3,81 @@ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, "hooks": { - "SessionStart": [ + "Notification": [ { "hooks": [ { - "type": "command", - "command": ".claude/skills/save-task-list/hooks/verify-task-list.sh", - "statusMessage": "Checking for restored task list..." + "command": "kild agent-status --self waiting 2\u003e/dev/null || true", + "timeout": 5, + "type": "command" } ] } ], - "UserPromptSubmit": [ + "PreCompact": [ { "hooks": [ { - "type": "command", - "command": "kild agent-status --self working 2>/dev/null || true", - "timeout": 5 + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": ".claude/skills/save-task-list/hooks/verify-task-list.sh", + "statusMessage": "Checking for restored task list...", + "type": "command" } ] + }, + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" } ], "Stop": [ { "hooks": [ { - "type": "command", - "command": "kild agent-status --self idle 2>/dev/null || true", - "timeout": 5 + "command": "kild agent-status --self idle 2\u003e/dev/null || true", + "timeout": 5, + "type": "command" } ] } ], "SubagentStop": [ { - "matcher": "rulecheck-agent", "hooks": [ { - "type": "command", "command": ".claude/skills/rulecheck/hooks/slack-notify.sh", - "statusMessage": "Notifying Slack..." + "statusMessage": "Notifying Slack...", + "type": "command" } - ] + ], + "matcher": "rulecheck-agent" } ], - "Notification": [ + "UserPromptSubmit": [ { "hooks": [ { - "type": "command", - "command": "kild agent-status --self waiting 2>/dev/null || true", - "timeout": 5 + "command": "kild agent-status --self working 2\u003e/dev/null || true", + "timeout": 5, + "type": "command" } ] } ] } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a2f33c5d5c..c861f9a169 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,8 @@ packages/server/.env skills-lock.json test-results/ .archon/ralph/ + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key diff --git a/.prettierignore b/.prettierignore index 5f7484c1a6..2f65458d7a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,6 +26,7 @@ package-lock.json .agents/ .claude/ .archon/ +.beads/ # Website (Astro/Starlight - uses own formatting) packages/docs-web/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..d1b57c0e59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** + +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** + +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use Claude Code memory (MEMORY.md) for session-persistent knowledge (user profile, feedback, preferences) +- Use `bd` only for issue tracking — NOT for memory storage (beads pushes to upstream fork, not your remote) + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** + +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/CLAUDE.md b/CLAUDE.md index a2b9d8d973..0345d81e97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -797,3 +797,51 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er - Parse `@archon` in issue/PR **comments only** (not descriptions) - Events: `issue_comment` only - Note: Descriptions often contain example commands or documentation - these are NOT command invocations (see #96) + + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use Claude Code memory (MEMORY.md) for session-persistent knowledge (user profile, feedback, preferences) +- Use `bd` only for issue tracking — NOT for memory storage (beads pushes to upstream fork, not your remote) + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/docs/superpowers/plans/2026-04-10-unified-memory-architecture.md b/docs/superpowers/plans/2026-04-10-unified-memory-architecture.md new file mode 100644 index 0000000000..3e45ba37a9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-unified-memory-architecture.md @@ -0,0 +1,371 @@ +# Unified Memory Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Archon agents access to the same MEMORY.md + topic files that CLI uses, eliminating memory fragmentation. + +**Architecture:** Add `computeMemoryPath(cwd)` to derive the CLI memory directory from the conversation's working directory. Inject MEMORY.md index into every prompt. Remove `context_summary` DB reads and Obsidian auto-load from prompt builder — MEMORY.md replaces both. + +**Tech Stack:** TypeScript, Node.js fs/promises, existing orchestrator-agent.ts + +--- + +### Task 1: Add `computeMemoryPath()` and `loadMemoryIndex()` + +**Files:** +- Modify: `packages/core/src/orchestrator/orchestrator-agent.ts` (Shared Memory section, ~line 1371) + +- [ ] **Step 1: Add `computeMemoryPath` function after the existing `VAULT_SESSION_LOGS` constant** + +```typescript +// ─── Project Memory (MEMORY.md — shared with CLI) ────────────────────────── + +/** + * Compute the path to Claude Code's per-project memory directory. + * CLI encodes the CWD by replacing '/' with '-' as the project folder name. + * Example: /Users/anton/Claude workspace/ai-ofm + * → ~/.claude/projects/-Users-anton-Claude-workspace-ai-ofm/memory/ + */ +function computeMemoryPath(cwd: string): string { + const encoded = cwd.replace(/\//g, '-'); + const home = process.env.HOME ?? ''; + return join(home, '.claude', 'projects', encoded, 'memory'); +} + +/** + * Load MEMORY.md index from the CLI memory directory for a project. + * Returns the file content (typically 10-50 lines) or null if not found. + * This is the same file Claude Code CLI auto-loads — sharing it gives + * Archon agents identical project knowledge. + */ +async function loadMemoryIndex(cwd: string): Promise { + try { + const memoryDir = computeMemoryPath(cwd); + const indexPath = join(memoryDir, 'MEMORY.md'); + if (!existsSync(indexPath)) return null; + + const content = await readFile(indexPath, 'utf-8'); + if (!content.trim()) return null; + + getLog().debug({ cwd, memoryDir }, 'memory.index_loaded'); + return content.trim(); + } catch (error) { + getLog().warn({ err: error as Error, cwd }, 'memory.index_load_failed'); + return null; + } +} +``` + +- [ ] **Step 2: Verify imports exist at the top of the file** + +Confirm these imports are present (they were added in a previous commit): +```typescript +import { writeFile, readFile, readdir, mkdir } from 'fs/promises'; +import { join } from 'path'; +``` + +If `readFile` is missing from the import, add it. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/orchestrator/orchestrator-agent.ts +git commit -m "feat: add computeMemoryPath and loadMemoryIndex utilities + +Computes the Claude Code CLI memory directory path from a project's +CWD and loads the MEMORY.md index for prompt injection." +``` + +--- + +### Task 2: Replace `context_summary` / Obsidian auto-load with MEMORY.md in `buildFullPrompt()` + +**Files:** +- Modify: `packages/core/src/orchestrator/orchestrator-agent.ts` (`buildFullPrompt()` function, ~line 450-505) + +- [ ] **Step 1: Replace the context loading block in `buildFullPrompt()`** + +Find this block (approximately lines 467-483): +```typescript + // Load context: prefer DB summary, fall back to latest Obsidian session log + let contextContent = conversation.context_summary; + if (!contextContent && conversation.codebase_id) { + const codebase = codebases.find(c => c.id === conversation.codebase_id); + if (codebase) { + contextContent = await loadLatestSessionLog(getProjectSlug(codebase)); + } + } + + const summarySuffix = contextContent + ? '\n\n---\n\n## Previous Session Context\n\nThe following is context from a prior session (shared across CLI and Telegram). Use it to maintain continuity.\nFor more history, check Obsidian vault at `Claude/Session-Logs/` using Obsidian MCP tools.\n\n' + + contextContent + : ''; +``` + +Replace with: +```typescript + // Load project memory (MEMORY.md) — shared with CLI Claude Code + let memoryContent: string | null = null; + if (conversation.cwd) { + memoryContent = await loadMemoryIndex(conversation.cwd); + } + + const memorySuffix = memoryContent + ? '\n\n---\n\n## Project Memory\n\nLoaded from MEMORY.md (shared with CLI). Topic files can be read on demand via the Read tool at: `' + + computeMemoryPath(conversation.cwd ?? '') + '/`\n' + + 'For session history, check Obsidian vault at `Claude/Session-Logs/` via Obsidian MCP or filesystem.\n\n' + + memoryContent + : ''; +``` + +- [ ] **Step 2: Update both return statements to use `memorySuffix` instead of `summarySuffix`** + +Replace the two returns (thread context and non-thread): +```typescript + if (threadContext) { + return ( + systemPrompt + + memorySuffix + + '\n\n---\n\n## Thread Context (previous messages)\n\n' + + threadContext + + '\n\n---\n\n## Current Request\n\n' + + message + + contextSuffix + + fileSuffix + ); + } + + return systemPrompt + memorySuffix + '\n\n---\n\n## User Message\n\n' + message + contextSuffix + fileSuffix; +``` + +- [ ] **Step 3: Run type-check** + +```bash +bun run type-check +``` +Expected: all packages exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/orchestrator/orchestrator-agent.ts +git commit -m "feat: inject MEMORY.md into prompt instead of context_summary + +Replaces DB context_summary and Obsidian auto-load with MEMORY.md +index — the same file CLI uses. Both interfaces now share one +source of project knowledge." +``` + +--- + +### Task 3: Update `/resume` to show MEMORY.md content + +**Files:** +- Modify: `packages/core/src/orchestrator/orchestrator-agent.ts` (`handleResume()` function, ~line 1585) + +- [ ] **Step 1: Replace the `handleResume` function** + +Find: +```typescript +async function handleResume( + platform: IPlatformAdapter, + conversationId: string, + conversation: Conversation +): Promise { + if (!conversation.context_summary) { + await platform.sendMessage( + conversationId, + 'No saved context. Use `/compact` first to save a conversation summary.' + ); + return; + } + + const preview = + conversation.context_summary.length > 500 + ? conversation.context_summary.slice(0, 500) + '...' + : conversation.context_summary; + + await platform.sendMessage( + conversationId, + `**Saved context** (${String(conversation.context_summary.length)} chars):\n\n${preview}\n\n_This context is automatically loaded into every new message._` + ); +} +``` + +Replace with: +```typescript +async function handleResume( + platform: IPlatformAdapter, + conversationId: string, + conversation: Conversation +): Promise { + // Show MEMORY.md content (shared with CLI) + const memoryContent = conversation.cwd ? await loadMemoryIndex(conversation.cwd) : null; + + if (!memoryContent) { + await platform.sendMessage( + conversationId, + 'No project memory found. Memory is shared with CLI — work in either interface to build it up.' + ); + return; + } + + const preview = memoryContent.length > 1000 + ? memoryContent.slice(0, 1000) + '\n...(truncated)' + : memoryContent; + + const memoryPath = computeMemoryPath(conversation.cwd ?? ''); + await platform.sendMessage( + conversationId, + `**Project Memory** (${String(memoryContent.length)} chars, shared with CLI):\n\n${preview}\n\nPath: \`${memoryPath}/\`` + ); +} +``` + +- [ ] **Step 2: Run type-check** + +```bash +bun run type-check +``` +Expected: all packages exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/orchestrator/orchestrator-agent.ts +git commit -m "feat: /resume shows MEMORY.md content instead of DB summary + +Shows the shared project memory that both CLI and Archon use, +with path to the memory directory for transparency." +``` + +--- + +### Task 4: Stop writing `context_summary` to DB in auto-compact + +**Files:** +- Modify: `packages/core/src/orchestrator/orchestrator-agent.ts` (auto-compact catch block, ~line 845 and handleCompact, ~line 1530) + +- [ ] **Step 1: Remove `context_summary` write from auto-compact catch block** + +Find in the catch block (~line 862): +```typescript + if (summary.trim()) { + await db.updateConversationSummary(conversation.id, summary.trim()); + } +``` + +Replace with: +```typescript + // Summary is written to Obsidian by /compact, not to DB. + // Auto-compact only resets the session — MEMORY.md provides context for the next session. +``` + +- [ ] **Step 2: Remove `context_summary` write from `handleCompact`** + +Find in `handleCompact` (~line 1540): +```typescript + await db.updateConversationSummary(conversation.id, trimmedSummary); +``` + +Remove this line. The `/compact` already saves to Obsidian — no need for DB cache. + +- [ ] **Step 3: Run type-check** + +```bash +bun run type-check +``` +Expected: all packages exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/orchestrator/orchestrator-agent.ts +git commit -m "refactor: stop writing context_summary to DB + +MEMORY.md is now the primary context source. Obsidian session logs +remain as supplementary history. DB context_summary column kept +for backward compatibility but no longer written." +``` + +--- + +### Task 5: Update `/help` text and orchestrator rules doc + +**Files:** +- Modify: `packages/core/src/handlers/command-handler.ts` (help text) +- Modify: `.claude/rules/orchestrator.md` + +- [ ] **Step 1: Update `/resume` description in help text** + +In `command-handler.ts`, find: +```typescript +- \`/resume\` — Show saved context summary +``` + +Replace with: +```typescript +- \`/resume\` — Show project memory (shared with CLI) +``` + +- [ ] **Step 2: Update orchestrator.md `/resume` description** + +In `.claude/rules/orchestrator.md`, find the table row for `/resume`: +``` +| `/resume` | Handled inline — shows stored context summary | +``` + +Replace with: +``` +| `/resume` | Handled inline — shows MEMORY.md content (shared with CLI) | +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/handlers/command-handler.ts .claude/rules/orchestrator.md +git commit -m "docs: update /resume description to reflect shared memory" +``` + +--- + +### Task 6: Verify end-to-end + push + +- [ ] **Step 1: Run full validation** + +```bash +bun run validate +``` +Expected: type-check, lint, format, tests all pass. + +- [ ] **Step 2: Manual verification** + +Check that `computeMemoryPath` produces correct paths: +```bash +# In node/bun REPL: +const cwd = '/Users/anton/Claude workspace/ai-ofm'; +const encoded = cwd.replace(/\//g, '-'); +console.log(`${process.env.HOME}/.claude/projects/${encoded}/memory`); +# Expected: /Users/anton/.claude/projects/-Users-anton-Claude-workspace-ai-ofm/memory +``` + +Verify MEMORY.md exists at that path: +```bash +ls ~/.claude/projects/-Users-anton-Claude-workspace-ai-ofm/memory/MEMORY.md +``` + +- [ ] **Step 3: Push all changes** + +```bash +git push +``` + +- [ ] **Step 4: Restart server and test in Telegram** + +```bash +pkill -f "bun.*watch.*index.ts"; sleep 1 +cd packages/server && bun --watch src/index.ts &>/tmp/archon-dev.log & +``` + +Send a message in the ai-ofm Telegram topic. Check logs for `memory.index_loaded`. +Send `/resume` — should show MEMORY.md content. diff --git a/docs/superpowers/specs/2026-04-10-unified-memory-architecture-design.md b/docs/superpowers/specs/2026-04-10-unified-memory-architecture-design.md new file mode 100644 index 0000000000..1ce91c7010 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-unified-memory-architecture-design.md @@ -0,0 +1,131 @@ +# Unified Memory Architecture for Archon + +**Date**: 2026-04-10 +**Status**: Approved +**Goal**: Eliminate memory fragmentation between CLI (Claude Code terminal) and Archon (Telegram/Slack/Web) by sharing the same 4-layer memory system. + +## Problem + +Currently 5 separate memory systems exist with no cross-visibility: +- CLI writes to MEMORY.md — Archon can't see it +- Archon writes to `context_summary` in DB — CLI can't see it +- Obsidian session logs partially bridged but inconsistently +- Beads used for both issue tracking and memory (conflated roles) + +Result: switching between CLI and Telegram loses context. + +## Architecture: 4 Layers + +Each layer has a single purpose. Both CLI and Archon read/write the same files. + +### Layer 1: CLAUDE.md (HOW to work) +- **Location**: `{project}/CLAUDE.md` (in repo) +- **Purpose**: Instructions, conventions, rules +- **Already shared**: Both read via `settingSources: [project, user]` +- **Changes needed**: None + +### Layer 2: MEMORY.md + topic files (WHAT we know) +- **Location**: `~/.claude/projects/{encoded-cwd}/memory/` +- **Purpose**: User profile, feedback, project decisions, persistent knowledge +- **Path encoding**: CWD with `/` replaced by `-`, prefixed with `-` + - Example: `/Users/anton/Claude workspace/ai-ofm` → `-Users-anton-Claude-workspace-ai-ofm` + - Full: `~/.claude/projects/-Users-anton-Claude-workspace-ai-ofm/memory/MEMORY.md` +- **CLI**: Auto-loads MEMORY.md index into context (existing behavior) +- **Archon**: Compute path from `conversation.cwd`, auto-inject MEMORY.md index into prompt +- **Writing**: Both can create/update topic files. Agent decides when insights are worth persisting. +- **Changes needed**: + - Add `computeMemoryPath(cwd)` utility + - Read MEMORY.md in `buildFullPrompt()` and inject into prompt + - Remove `context_summary` column usage (keep column for backward compat, stop writing) + +### Layer 3: Obsidian Session Logs (WHAT we did) +- **Location**: `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/Claude/Session-Logs/{project}/` +- **Purpose**: Session timeline, work history, handoff context +- **CLI**: `/compress` writes, `/resume` reads +- **Archon**: `/compact` writes (already implemented), fallback read when MEMORY.md has no relevant context +- **Changes needed**: Keep existing Obsidian save in `/compact`. Remove auto-load from prompt builder (MEMORY.md replaces this). + +### Layer 4: Beads (WHAT we're working on) +- **Location**: `{project}/.beads/` +- **Purpose**: Issue tracking, task context, agent task-specific insights +- **CLI**: `bd list`, `bd show`, `bd close`, `bd remember` +- **Archon**: Same commands via Bash (agents working on tasks use beads for issue context) +- **Changes needed**: None — already accessible via Bash when CLAUDE.md mentions it + +## Implementation Details + +### Path computation + +```typescript +function computeMemoryPath(cwd: string): string { + const encoded = cwd.replace(/\//g, '-'); + const home = process.env.HOME ?? ''; + return `${home}/.claude/projects/${encoded}/memory`; +} +``` + +### Prompt injection + +In `buildFullPrompt()`: +1. If `conversation.cwd` exists → compute memory path +2. If `MEMORY.md` exists at that path → read it (typically 10-50 lines) +3. Inject as `## Project Memory` section in the prompt +4. Agent can read individual topic files via Read tool when needed + +### What gets removed + +- `context_summary` column: stop writing to it. Keep column in schema (no migration needed). +- `loadLatestSessionLog()` from prompt builder: MEMORY.md replaces this as the primary context source. +- Obsidian auto-read in `buildFullPrompt()`: removed. Obsidian remains write-only from `/compact` and readable by the agent on demand. + +### What stays + +- `/compact` command: resets session + writes summary to Obsidian. Does NOT write to MEMORY.md (that's the agent's choice during normal work). +- `/resume` command: shows what context is available (MEMORY.md content). +- Auto-compact on expired session: still works — summarizes from message history, resets session. The new session picks up MEMORY.md context automatically. +- Message persistence: still saves user + assistant messages to DB for auto-compact fallback. + +## Data flow + +``` +User message in Telegram + → orchestrator loads conversation (has cwd) + → computeMemoryPath(cwd) → read MEMORY.md + → buildFullPrompt() includes MEMORY.md index + → agent responds with full project context + → agent can Read topic files, Obsidian logs, bd show as needed + → messages saved to DB (for auto-compact fallback) + +/compact in Telegram + → AI summarizes session + → writes to Obsidian Session-Logs/{project}/ + → resets session + → next message → loads MEMORY.md again (fresh session, same knowledge) + +Agent learns something important + → agent writes/updates topic file in memory path (Write tool) + → updates MEMORY.md index + → knowledge persists for both CLI and Archon +``` + +## Files to modify + +1. `packages/core/src/orchestrator/orchestrator-agent.ts` + - Add `computeMemoryPath()` utility + - Update `buildFullPrompt()`: read MEMORY.md, inject as context + - Remove `loadLatestSessionLog()` from prompt injection (keep function for `/compact` Obsidian write) + - Remove `context_summary` reads from prompt + - Update `/resume` to show MEMORY.md content instead of DB summary + +2. `packages/core/src/orchestrator/prompt-builder.ts` + - No changes (prompt structure built in orchestrator-agent.ts) + +3. No DB migrations needed (keep `context_summary` column, just stop using it) + +## Success criteria + +- Open CLI in ai-ofm → agent knows user profile, feedback, project state from MEMORY.md +- Open Telegram ai-ofm topic → agent knows the SAME information +- Work in CLI → `/compress` → switch to Telegram → agent sees session log +- Work in Telegram → agent updates memory topic file → switch to CLI → CLI sees the update +- Beads issues visible to both via `bd` commands diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 176963b40e..201192d884 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_conversations ( title VARCHAR(255), deleted_at TIMESTAMP WITH TIME ZONE, hidden BOOLEAN DEFAULT FALSE, + context_summary TEXT, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), diff --git a/migrations/022_add_context_summary.sql b/migrations/022_add_context_summary.sql new file mode 100644 index 0000000000..c45fc98196 --- /dev/null +++ b/migrations/022_add_context_summary.sql @@ -0,0 +1,5 @@ +-- Add context_summary to conversations for /compact session compression +-- Stores AI-generated summary of conversation context for session continuity + +ALTER TABLE remote_agent_conversations +ADD COLUMN IF NOT EXISTS context_summary TEXT; diff --git a/packages/adapters/src/chat/telegram/adapter.ts b/packages/adapters/src/chat/telegram/adapter.ts index c800612079..7255c60415 100644 --- a/packages/adapters/src/chat/telegram/adapter.ts +++ b/packages/adapters/src/chat/telegram/adapter.ts @@ -55,55 +55,86 @@ export class TelegramAdapter implements IPlatformAdapter { * - Short messages (≤4096 chars): Convert to MarkdownV2 for nice formatting * - Long messages: Split by paragraphs, format each chunk independently * (paragraphs rarely have formatting that spans across them) + * + * Forum topic support: + * - If chatId contains ":" (e.g. "-100123456:789"), the second part is the + * message_thread_id and replies go to that specific forum topic. */ async sendMessage(chatId: string, message: string, _metadata?: MessageMetadata): Promise { - const id = parseInt(chatId); - getLog().debug({ chatId, messageLength: message.length }, 'telegram.send_message'); + const { numericChatId, threadId } = this.parseChatId(chatId); + getLog().debug({ chatId, threadId, messageLength: message.length }, 'telegram.send_message'); if (message.length <= MAX_LENGTH) { - // Short message: try MarkdownV2 formatting - await this.sendFormattedChunk(id, message); + await this.sendFormattedChunk(numericChatId, message, threadId); } else { - // Long message: split by paragraphs, format each chunk getLog().debug({ messageLength: message.length }, 'telegram.message_splitting'); const chunks = splitIntoParagraphChunks(message, MAX_LENGTH - 200); for (const chunk of chunks) { - await this.sendFormattedChunk(id, chunk); + await this.sendFormattedChunk(numericChatId, chunk, threadId); } } } /** - * Send a single chunk with MarkdownV2 formatting, with fallback to plain text + * Parse a chatId that may contain a forum topic thread ID. + * Format: "chatId" or "chatId:threadId" */ - private async sendFormattedChunk(id: number, chunk: string): Promise { + private parseChatId(chatId: string): { numericChatId: number; threadId: number | undefined } { + const parts = chatId.split(':'); + return { + numericChatId: parseInt(parts[0]), + threadId: parts[1] ? parseInt(parts[1]) : undefined, + }; + } + + /** + * Send a single chunk with MarkdownV2 formatting, with fallback to plain text. + * If threadId is provided, sends to that forum topic. + */ + private async sendFormattedChunk(id: number, chunk: string, threadId?: number): Promise { + // Build options: include thread ID only when targeting a forum topic + const threadExtra = threadId ? { message_thread_id: threadId } : undefined; + // If chunk is still too long after paragraph splitting, fall back to plain text if (chunk.length > MAX_LENGTH) { getLog().debug({ chunkLength: chunk.length }, 'telegram.chunk_too_long_plain_text'); const plainText = stripMarkdown(chunk); - // Split by lines if still too long const lines = plainText.split('\n'); let subChunk = ''; for (const line of lines) { if (subChunk.length + line.length + 1 > MAX_LENGTH - 100) { - if (subChunk) await this.bot.telegram.sendMessage(id, subChunk); + if (subChunk) { + if (threadExtra) { + await this.bot.telegram.sendMessage(id, subChunk, threadExtra); + } else { + await this.bot.telegram.sendMessage(id, subChunk); + } + } subChunk = line; } else { subChunk += (subChunk ? '\n' : '') + line; } } - if (subChunk) await this.bot.telegram.sendMessage(id, subChunk); + if (subChunk) { + if (threadExtra) { + await this.bot.telegram.sendMessage(id, subChunk, threadExtra); + } else { + await this.bot.telegram.sendMessage(id, subChunk); + } + } return; } // Try MarkdownV2 formatting const formatted = convertToTelegramMarkdown(chunk); + const markdownOptions = threadExtra + ? { parse_mode: 'MarkdownV2' as const, ...threadExtra } + : { parse_mode: 'MarkdownV2' as const }; try { - await this.bot.telegram.sendMessage(id, formatted, { parse_mode: 'MarkdownV2' }); - getLog().debug({ chunkLength: chunk.length }, 'telegram.markdownv2_chunk_sent'); + await this.bot.telegram.sendMessage(id, formatted, markdownOptions); + getLog().debug({ chunkLength: chunk.length, threadId }, 'telegram.markdownv2_chunk_sent'); } catch (error) { - // Fallback to stripped plain text for this chunk const err = error as Error; getLog().warn( { @@ -113,7 +144,11 @@ export class TelegramAdapter implements IPlatformAdapter { }, 'telegram.markdownv2_failed' ); - await this.bot.telegram.sendMessage(id, stripMarkdown(chunk)); + if (threadExtra) { + await this.bot.telegram.sendMessage(id, stripMarkdown(chunk), threadExtra); + } else { + await this.bot.telegram.sendMessage(id, stripMarkdown(chunk)); + } } } @@ -139,18 +174,29 @@ export class TelegramAdapter implements IPlatformAdapter { } /** - * Extract conversation ID from Telegram context + * Extract conversation ID from Telegram context. + * For forum topics (supergroups with topics enabled), includes the thread ID + * so each topic gets its own conversation: "chatId:threadId". + * For regular chats/groups, returns just the chat ID. */ getConversationId(ctx: Context): string { if (!ctx.chat) { throw new Error('No chat in context'); } - return ctx.chat.id.toString(); + const chatId = ctx.chat.id.toString(); + + // Check for forum topic (message_thread_id present on topic messages) + const msg = ctx.message; + if (msg && 'message_thread_id' in msg && msg.message_thread_id) { + return `${chatId}:${msg.message_thread_id}`; + } + + return chatId; } /** * Ensure responses go to a thread. - * Telegram doesn't have threads - each chat is a persistent conversation. + * For forum topics, the thread is already encoded in the conversation ID. * Returns original conversation ID unchanged. */ async ensureThread(originalConversationId: string, _messageContext?: unknown): Promise { @@ -188,6 +234,16 @@ export class TelegramAdapter implements IPlatformAdapter { if (this.messageHandler) { const conversationId = this.getConversationId(ctx); + // Debug: log forum topic detection + const msg = ctx.message; + const threadId = + 'message_thread_id' in msg + ? (msg as { message_thread_id?: number }).message_thread_id + : undefined; + getLog().info( + { chatId: ctx.chat?.id, threadId, conversationId, chatType: ctx.chat?.type }, + 'telegram.message_received' + ); // Fire-and-forget - errors handled by caller void this.messageHandler({ conversationId, message, userId }); } diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 485706d040..a8642614b0 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -178,6 +178,9 @@ export class SqliteAdapter implements IDatabase { if (!colNames.has('hidden')) { this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN hidden INTEGER DEFAULT 0'); } + if (!colNames.has('context_summary')) { + this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN context_summary TEXT'); + } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_conversations_columns_failed'); } @@ -264,6 +267,7 @@ export class SqliteAdapter implements IDatabase { title TEXT, deleted_at TEXT, hidden INTEGER DEFAULT 0, + context_summary TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), last_activity_at TEXT DEFAULT (datetime('now')), diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..d63083acae 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -244,6 +244,20 @@ export async function updateConversationTitle(id: string, title: string): Promis } } +/** + * Update conversation context summary (used by /compact) + */ +export async function updateConversationSummary(id: string, summary: string | null): Promise { + const dialect = getDialect(); + const result = await pool.query( + `UPDATE remote_agent_conversations SET context_summary = $1, updated_at = ${dialect.now()} WHERE id = $2`, + [summary, id] + ); + if (result.rowCount === 0) { + throw new ConversationNotFoundError(id); + } +} + /** * Soft delete a conversation (sets deleted_at timestamp) */ diff --git a/packages/core/src/handlers/command-handler.ts b/packages/core/src/handlers/command-handler.ts index 94227e54b9..03196b72f3 100644 --- a/packages/core/src/handlers/command-handler.ts +++ b/packages/core/src/handlers/command-handler.ts @@ -919,12 +919,15 @@ Talk naturally — the orchestrator routes your requests to the right workflow a - \`/workflow reject \` — Reject a paused run **Projects** +- \`/setproject \` — Bind a project to this conversation - \`/register-project \` — Register a local project - \`/update-project \` — Update a project's path - \`/remove-project \` — Remove a registered project **Session** - \`/status\` — Show current session and project info +- \`/compact\` — Summarize and compress the session (frees context window) +- \`/resume\` — Show project memory (shared with CLI) - \`/reset\` — Clear conversation and start fresh - \`/help\` — Show this help message diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index ca86f79a68..3bd5e8c4d9 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -7,6 +7,8 @@ * - Does NOT require a project to be selected before starting a conversation */ import { existsSync } from 'fs'; +import { writeFile, readFile, mkdir } from 'fs/promises'; +import { join } from 'path'; import { createLogger } from '@archon/paths'; import type { IPlatformAdapter, @@ -21,6 +23,7 @@ import * as db from '../db/conversations'; import * as codebaseDb from '../db/codebases'; import * as sessionDb from '../db/sessions'; import * as commandHandler from '../handlers/command-handler'; +import * as messageDb from '../db/messages'; import { formatToolCall } from '@archon/workflows/utils/tool-formatter'; import { classifyAndFormatError } from '../utils/error-formatter'; import { toError } from '../utils/error'; @@ -444,7 +447,7 @@ async function discoverAllWorkflows(conversation: Conversation): Promise { const scopedCodebase = conversation.codebase_id ? codebases.find(c => c.id === conversation.codebase_id) : undefined; @@ -461,6 +464,20 @@ function buildFullPrompt( ? buildProjectScopedPrompt(scopedCodebase, codebases, workflows) : buildOrchestratorPrompt(codebases, workflows); + // Load project memory (MEMORY.md) — shared with CLI Claude Code + let memoryContent: string | null = null; + if (conversation.cwd) { + memoryContent = await loadMemoryIndex(conversation.cwd); + } + + const memorySuffix = memoryContent + ? '\n\n---\n\n## Project Memory\n\nLoaded from MEMORY.md (shared with CLI). Topic files can be read on demand via the Read tool at: `' + + computeMemoryPath(conversation.cwd ?? '') + + '/`\n' + + 'For session history, check Obsidian vault at `Claude/Session-Logs/` via Obsidian MCP or filesystem.\n\n' + + memoryContent + : ''; + const contextSuffix = issueContext ? '\n\n---\n\n## Additional Context\n\n' + issueContext : ''; const fileSuffix = @@ -474,6 +491,7 @@ function buildFullPrompt( if (threadContext) { return ( systemPrompt + + memorySuffix + '\n\n---\n\n## Thread Context (previous messages)\n\n' + threadContext + '\n\n---\n\n## Current Request\n\n' + @@ -483,7 +501,14 @@ function buildFullPrompt( ); } - return systemPrompt + '\n\n---\n\n## User Message\n\n' + message + contextSuffix + fileSuffix; + return ( + systemPrompt + + memorySuffix + + '\n\n---\n\n## User Message\n\n' + + message + + contextSuffix + + fileSuffix + ); } // ─── Main Handler ─────────────────────────────────────────────────────────── @@ -502,11 +527,12 @@ export async function handleMessage( ): Promise { const { issueContext, threadContext, parentConversationId, isolationHints, attachedFiles } = context ?? {}; + let conversation: Conversation | undefined; try { getLog().debug({ conversationId }, 'orchestrator_message_received'); // 1. Get/create conversation and inherit thread context - let conversation = await db.getOrCreateConversation( + conversation = await db.getOrCreateConversation( platform.getPlatformType(), conversationId, undefined, @@ -655,6 +681,9 @@ export async function handleMessage( 'register-project', 'update-project', 'remove-project', + 'setproject', + 'compact', + 'resume', 'commands', 'init', 'worktree', @@ -682,6 +711,31 @@ export async function handleMessage( return; } + if (command === 'setproject') { + getLog().debug({ command, conversationId }, 'deterministic_command'); + const result = await handleSetProject(message, conversation.id); + await platform.sendMessage(conversationId, result); + return; + } + + if (command === 'reset') { + getLog().debug({ command, conversationId }, 'deterministic_command'); + await handleResetWithSessionLog(platform, conversationId, conversation); + return; + } + + if (command === 'compact') { + getLog().debug({ command, conversationId }, 'deterministic_command'); + await handleCompact(platform, conversationId, conversation); + return; + } + + if (command === 'resume') { + getLog().debug({ command, conversationId }, 'deterministic_command'); + await handleResume(platform, conversationId, conversation); + return; + } + getLog().debug({ command, conversationId }, 'deterministic_command'); const result = await commandHandler.handleCommand(conversation, message); await platform.sendMessage(conversationId, result.message); @@ -731,7 +785,7 @@ export async function handleMessage( }); } - const fullPrompt = buildFullPrompt( + const fullPrompt = await buildFullPrompt( conversation, codebases, workflows, @@ -801,6 +855,50 @@ export async function handleMessage( getLog().debug({ conversationId }, 'orchestrator_message_completed'); } catch (error) { const err = toError(error); + + // Auto-compact on expired session: save summary from messages, reset, and retry + const isSessionExpired = + err.message.includes('No conversation found with session ID') || + err.message.includes('not a valid UUID') || + (err.message.includes('session') && err.message.includes('not found')); + if (conversation && isSessionExpired) { + getLog().info({ conversationId }, 'session.expired_auto_compacting'); + try { + // Save session log to Obsidian before resetting + await saveSessionToObsidian(conversation); + + // Reset the expired session + const expiredSession = await sessionDb.getActiveSession(conversation.id); + if (expiredSession) { + await sessionDb.deactivateSession(expiredSession.id, 'reset-requested'); + } + + await platform.sendMessage( + conversationId, + 'Session expired — context saved automatically. Retrying your message...' + ); + + // Retry once (guard against infinite recursion) + const retryDepth = + ((context as Record | undefined)?._retryDepth as number | undefined) ?? + 0; + if (retryDepth > 0) { + getLog().error({ conversationId, retryDepth }, 'session.auto_compact_retry_limit'); + } else { + await handleMessage(platform, conversationId, message, { + ...context, + _retryDepth: retryDepth + 1, + } as HandleMessageContext); + return; + } + } catch (compactError) { + getLog().error( + { err: toError(compactError), conversationId }, + 'session.auto_compact_failed' + ); + } + } + getLog().error({ err, conversationId }, 'orchestrator_message_failed'); const userMessage = classifyAndFormatError(err); try { @@ -890,6 +988,10 @@ async function handleStreamMode( } const fullResponse = allMessages.join(''); + + // Persist messages for all platforms (enables auto-compact, /compact, history) + await persistConversationMessages(conversation.id, originalMessage, fullResponse); + const commands = parseOrchestratorCommands(fullResponse, codebases, workflows); if (commands.workflowInvocation) { @@ -1023,6 +1125,9 @@ async function handleBatchMode( return; } + // Persist messages for all platforms (enables auto-compact, /compact, history) + await persistConversationMessages(conversation.id, originalMessage, finalMessage); + // Parse orchestrator commands from filtered response const commands = parseOrchestratorCommands(finalMessage, codebases, workflows); @@ -1265,6 +1370,346 @@ async function handleRemoveProject(message: string): Promise { return `Project "${projectName}" removed.\nPath was: ${codebase.default_cwd}`; } +// ─── Shared Memory (Obsidian Session Logs) ───────────────────────────────── + +/** Obsidian vault path (iCloud). Both CLI (/compress) and Archon (/compact) use this. */ +const VAULT_SESSION_LOGS = join( + process.env.HOME ?? '', + 'Library/Mobile Documents/iCloud~md~obsidian/Documents/Claude/Session-Logs' +); + +/** + * Resolve the project folder name from a codebase (last segment of name). + * e.g., "CryptixSamurai/ai-ofm" → "ai-ofm" + */ +function getProjectSlug(codebase: Codebase): string { + const parts = codebase.name.split('/'); + return parts[parts.length - 1]; +} + +/** + * Save a session summary to Obsidian vault as a session log. + * Mirrors /compress CLI format so both tools produce a unified timeline. + */ +async function saveSessionLogToVault( + projectSlug: string, + summary: string, + platform: string, + title?: string | null +): Promise { + try { + const dir = join(VAULT_SESSION_LOGS, projectSlug); + await mkdir(dir, { recursive: true }); + + const date = new Date().toISOString().slice(0, 10); + const slug = (title ?? 'compact-session') + .toLowerCase() + .replace(/[^a-z0-9а-яіїєґ]+/gu, '-') + .replace(/^-|-$/g, '') + .slice(0, 50); + const fileName = `${date}-${slug}.md`; + + await writeFile( + join(dir, fileName), + `---\ntype: session-log\ndate: ${date}\nproject: ${projectSlug}\nsource: archon-compact\nplatform: ${platform}\nstatus: completed\ntags: [claude, session, ${projectSlug.toLowerCase()}, archon]\n---\n\n# ${projectSlug} — Compact Summary\n\n${summary}\n`, + 'utf-8' + ); + return `Claude/Session-Logs/${projectSlug}/${fileName}`; + } catch (error) { + getLog().warn({ err: error as Error, projectSlug }, 'session.vault_save_failed'); + return null; + } +} + +// ─── Project Memory (MEMORY.md — shared with CLI) ────────────────────────── + +/** + * Compute the path to Claude Code's per-project memory directory. + * CLI encodes the CWD by replacing '/' and spaces with '-' as the project folder name. + * Example: /Users/anton/Claude workspace/ai-ofm + * → ~/.claude/projects/-Users-anton-Claude-workspace-ai-ofm/memory/ + */ +export function computeMemoryPath(cwd: string): string { + const encoded = cwd.replace(/[/. ]/g, '-'); + const home = process.env.HOME ?? ''; + return join(home, '.claude', 'projects', encoded, 'memory'); +} + +/** + * Load MEMORY.md index from the CLI memory directory for a project. + * Returns the file content (typically 10-50 lines) or null if not found. + * This is the same file Claude Code CLI auto-loads — sharing it gives + * Archon agents identical project knowledge. + */ +export async function loadMemoryIndex(cwd: string): Promise { + try { + const memoryDir = computeMemoryPath(cwd); + const indexPath = join(memoryDir, 'MEMORY.md'); + if (!existsSync(indexPath)) return null; + + const content = await readFile(indexPath, 'utf-8'); + if (!content.trim()) return null; + + getLog().debug({ cwd, memoryDir }, 'memory.index_loaded'); + return content.trim(); + } catch (error) { + getLog().warn({ err: error as Error, cwd }, 'memory.index_load_failed'); + return null; + } +} + +/** + * Persist user + assistant messages to the database. + * Fire-and-forget — errors are logged but never thrown. + * These messages power auto-compact summaries when sessions expire. + */ +async function persistConversationMessages( + conversationDbId: string, + userMessage: string, + assistantResponse: string +): Promise { + try { + // Skip responses containing orchestrator commands (not user-facing content) + if ( + /^\/invoke-workflow\s/m.test(assistantResponse) || + /^\/register-project\s/m.test(assistantResponse) + ) { + return; + } + await messageDb.addMessage(conversationDbId, 'user', userMessage); + await messageDb.addMessage(conversationDbId, 'assistant', assistantResponse); + } catch (error) { + getLog().warn({ err: error as Error, conversationDbId }, 'message.persist_failed'); + } +} + +/** + * Generate a summary from saved messages and write a session log to Obsidian. + * Used by /reset, /compact, and auto-compact to preserve session history. + * Returns the vault path on success, null if no messages or on failure. + */ +async function saveSessionToObsidian(conversation: Conversation): Promise { + const messages = await messageDb.listMessages(conversation.id, 50); + if (messages.length === 0) return null; + + const codebase = conversation.codebase_id + ? await codebaseDb.getCodebase(conversation.codebase_id) + : null; + if (!codebase) return null; + + const transcript = messages.map(m => `[${m.role}]: ${m.content.slice(0, 500)}`).join('\n\n'); + + const aiClient = getAgentProvider(conversation.ai_assistant_type); + const cwd = conversation.cwd ?? getArchonWorkspacesPath(); + let summary = ''; + + try { + for await (const chunk of aiClient.sendQuery( + `Summarize this conversation transcript concisely. Include: key decisions, current state of work, important context, and pending items. Output ONLY the summary, no preamble.\n\n---\n\n${transcript}`, + cwd, + undefined, + { nodeConfig: { allowed_tools: [] } } + )) { + if (chunk.type === 'assistant') summary += chunk.content; + } + } catch (error) { + getLog().warn({ err: error as Error }, 'session.summary_generation_failed'); + return null; + } + + if (!summary.trim()) return null; + + return saveSessionLogToVault( + getProjectSlug(codebase), + summary.trim(), + conversation.platform_type, + conversation.title + ); +} + +/** + * Handle /reset with session log preservation. + * Saves conversation history to Obsidian before resetting the session. + */ +async function handleResetWithSessionLog( + platform: IPlatformAdapter, + conversationId: string, + conversation: Conversation +): Promise { + const session = await sessionDb.getActiveSession(conversation.id); + if (!session) { + await platform.sendMessage(conversationId, 'No active session to reset.'); + return; + } + + // Save session log to Obsidian before resetting + const vaultPath = await saveSessionToObsidian(conversation); + await sessionDb.deactivateSession(session.id, 'reset-requested'); + + const logNote = vaultPath ? `\nSession log saved to Obsidian: ${vaultPath}` : ''; + await platform.sendMessage( + conversationId, + `Session cleared. Starting fresh on next message.${logNote}\n\nProject and memory preserved.` + ); +} + +/** + * Handle /compact command. + * Summarizes the current conversation via AI, saves the summary, and resets the session. + * Next message will include the summary as context for continuity. + */ +async function handleCompact( + platform: IPlatformAdapter, + conversationId: string, + conversation: Conversation +): Promise { + const session = await sessionDb.getActiveSession(conversation.id); + if (!session) { + await platform.sendMessage(conversationId, 'No active session to compact.'); + return; + } + + await platform.sendMessage(conversationId, 'Compacting session...'); + + const aiClient = getAgentProvider(conversation.ai_assistant_type); + const cwd = conversation.cwd ?? getArchonWorkspacesPath(); + let summary = ''; + + // Try resuming the existing session for summarization. + // If the session expired (server restart), fall back to message history. + const resumeId = session.assistant_session_id ?? undefined; + const summarizePrompt = + 'Summarize our entire conversation so far in a structured way. Include: key decisions made, current state of work, important context, and any pending items. Be concise but complete — this summary will be used to continue the conversation in a fresh session. Output ONLY the summary, no preamble.'; + + try { + for await (const chunk of aiClient.sendQuery(summarizePrompt, cwd, resumeId, { + nodeConfig: { allowed_tools: [] }, + })) { + if (chunk.type === 'assistant') { + summary += chunk.content; + } + } + } catch { + // Session expired — build summary from saved messages + getLog().info({ conversationId }, 'session.compact_resume_failed_using_messages'); + const messages = await messageDb.listMessages(conversation.id, 50); + if (messages.length === 0) { + await platform.sendMessage(conversationId, 'No messages to summarize. Use /reset instead.'); + return; + } + + const transcript = messages.map(m => `[${m.role}]: ${m.content.slice(0, 500)}`).join('\n\n'); + + const fallbackPrompt = `Summarize this conversation transcript. Include: key decisions, current state of work, important context, and pending items. Be concise but complete. Output ONLY the summary.\n\n---\n\n${transcript}`; + + for await (const chunk of aiClient.sendQuery(fallbackPrompt, cwd, undefined, { + nodeConfig: { allowed_tools: [] }, + })) { + if (chunk.type === 'assistant') { + summary += chunk.content; + } + } + } + + if (!summary.trim()) { + await platform.sendMessage(conversationId, 'Failed to generate summary. Session not reset.'); + return; + } + + // Save summary to Obsidian vault (shared with CLI /compress) + // context_summary writes removed — MEMORY.md is the shared memory now + const trimmedSummary = summary.trim(); + await sessionDb.deactivateSession(session.id, 'reset-requested'); + + let vaultPath: string | null = null; + if (conversation.codebase_id) { + const codebase = await codebaseDb.getCodebase(conversation.codebase_id); + if (codebase) { + vaultPath = await saveSessionLogToVault( + getProjectSlug(codebase), + trimmedSummary, + conversation.platform_type, + conversation.title + ); + } + } + + getLog().info( + { conversationId, summaryLength: trimmedSummary.length, vaultPath }, + 'session.compact_completed' + ); + const vaultNote = vaultPath ? `\nSaved to Obsidian: ${vaultPath}` : ''; + await platform.sendMessage( + conversationId, + `Session compacted. Summary saved (${String(trimmedSummary.length)} chars).${vaultNote}\nNext message will start a fresh session with full context.` + ); +} + +/** + * Handle /resume command. + * Shows the stored context summary and confirms it will be loaded on next message. + */ +async function handleResume( + platform: IPlatformAdapter, + conversationId: string, + conversation: Conversation +): Promise { + // Show MEMORY.md content (shared with CLI) + const memoryContent = conversation.cwd ? await loadMemoryIndex(conversation.cwd) : null; + + if (!memoryContent) { + await platform.sendMessage( + conversationId, + 'No project memory found. Memory is shared with CLI — work in either interface to build it up.' + ); + return; + } + + const preview = + memoryContent.length > 1000 ? memoryContent.slice(0, 1000) + '\n...(truncated)' : memoryContent; + + const memoryPath = computeMemoryPath(conversation.cwd ?? ''); + await platform.sendMessage( + conversationId, + `**Project Memory** (${String(memoryContent.length)} chars, shared with CLI):\n\n${preview}\n\nPath: \`${memoryPath}/\`` + ); +} + +/** + * Handle /setproject command. + * Binds a registered codebase to the current conversation so all subsequent + * messages route to that project automatically. + */ +async function handleSetProject(message: string, conversationDbId: string): Promise { + const { args } = commandHandler.parseCommand(message); + if (args.length < 1) { + return 'Usage: /setproject '; + } + + const projectName = args.join(' '); + + // Find codebase (case-insensitive, partial path match) + const codebases = await codebaseDb.listCodebases(); + const codebase = findCodebaseByName(codebases, projectName); + + if (!codebase) { + const available = codebases.map(c => c.name).join(', '); + return `Project "${projectName}" not found.\nRegistered projects: ${available || 'none'}`; + } + + // Update conversation record + await db.updateConversation(conversationDbId, { + codebase_id: codebase.id, + cwd: codebase.default_cwd, + }); + + getLog().info( + { conversationDbId, projectName: codebase.name, codebaseId: codebase.id }, + 'project.setproject_completed' + ); + return `Project set to **${codebase.name}**\nWorking directory: ${codebase.default_cwd}`; +} + /** * Handle /workflow run command when project context may be missing. * Implements Edge Case E2 from the plan. diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index de4618ed15..95583cc86e 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -494,17 +494,15 @@ describe('orchestrator-agent handleMessage', () => { expect(platform.sendMessage).toHaveBeenCalledWith('chat-456', 'Help text'); }); - test('delegates /reset to command handler', async () => { - mockHandleCommand.mockResolvedValue({ - message: 'Session cleared', - modified: false, - success: true, - }); + test('handles /reset with session log preservation', async () => { + // /reset is now intercepted by handleResetWithSessionLog before reaching handleCommand. + // With no active session, it sends a "no active session" message. + mockGetActiveSession.mockResolvedValueOnce(null); await handleMessage(platform, 'chat-456', '/reset'); - expect(mockHandleCommand).toHaveBeenCalled(); - expect(platform.sendMessage).toHaveBeenCalledWith('chat-456', 'Session cleared'); + expect(mockHandleCommand).not.toHaveBeenCalled(); + expect(platform.sendMessage).toHaveBeenCalledWith('chat-456', 'No active session to reset.'); }); test('uses CommandResult workflow definition without rediscovery for /workflow run', async () => { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 74966e3b2c..94e76fe789 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -29,6 +29,7 @@ export interface Conversation { ai_assistant_type: string; title: string | null; hidden: boolean; + context_summary: string | null; deleted_at: Date | null; last_activity_at: Date | null; // For staleness detection created_at: Date; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8099a8a9bd..dd30e52d41 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -441,7 +441,15 @@ export async function startServer(opts: ServerOptions = {}): Promise { }); // Register Web UI API routes - registerApiRoutes(app, webAdapter, lockManager); + // Lazy callback: telegram is initialized after server.listen(), so we read at request time + registerApiRoutes(app, webAdapter, lockManager, () => ({ + slack: slack !== null, + telegram: telegram !== null, + discord: discord !== null, + github: github !== null, + gitea: gitea !== null, + gitlab: gitlab !== null, + })); // GitHub webhook endpoint if (github) { diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 4bc814f685..d0435c4c35 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -787,6 +787,17 @@ const getCodebaseEnvironmentsRoute = createRoute({ }, }); +const adaptersSchema = z + .object({ + slack: z.boolean(), + telegram: z.boolean(), + discord: z.boolean(), + github: z.boolean(), + gitea: z.boolean(), + gitlab: z.boolean(), + }) + .openapi('Adapters'); + const getHealthRoute = createRoute({ method: 'get', path: '/api/health', @@ -800,6 +811,7 @@ const getHealthRoute = createRoute({ .object({ status: z.string(), adapter: z.string(), + adapters: adaptersSchema, concurrency: z.record(z.unknown()), runningWorkflows: z.number(), version: z.string().optional(), @@ -833,10 +845,28 @@ const getUpdateCheckRoute = createRoute({ /** * Register all /api/* routes on the Hono app. */ +/** Which platform adapters are currently active (instantiated and started). */ +export interface ActiveAdapters { + slack: boolean; + telegram: boolean; + discord: boolean; + github: boolean; + gitea: boolean; + gitlab: boolean; +} + export function registerApiRoutes( app: OpenAPIHono, webAdapter: WebAdapter, - lockManager: ConversationLockManager + lockManager: ConversationLockManager, + getActiveAdapters: () => ActiveAdapters = () => ({ + slack: false, + telegram: false, + discord: false, + github: false, + gitea: false, + gitlab: false, + }) ): void { function apiError( c: Context, @@ -2502,6 +2532,7 @@ export function registerApiRoutes( return c.json({ status: 'ok', adapter: 'web', + adapters: getActiveAdapters(), concurrency: { ...stats, active: allActiveIds.length, diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 81a3529833..4f64bc5079 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -46,6 +46,14 @@ export interface CodebaseResponse { export interface HealthResponse { status: string; adapter: string; + adapters?: { + slack: boolean; + telegram: boolean; + discord: boolean; + github: boolean; + gitea: boolean; + gitlab: boolean; + }; concurrency: { active: number; queuedTotal: number; diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 0b9c7b6e60..1b22d5e17f 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -16,7 +16,7 @@ import { setCodebaseEnvVar, deleteCodebaseEnvVar, } from '@/lib/api'; -import type { SafeConfigResponse, CodebaseResponse } from '@/lib/api'; +import type { SafeConfigResponse, CodebaseResponse, HealthResponse } from '@/lib/api'; const selectClass = 'h-9 rounded-md border border-border bg-surface-elevated text-text-primary px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring [&>option]:bg-surface-elevated [&>option]:text-text-primary'; @@ -532,15 +532,17 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea function PlatformConnectionsSection({ adapter, + adapters, }: { adapter: string | undefined; + adapters: HealthResponse['adapters'] | undefined; }): React.ReactElement { const platforms = [ { name: 'Web', connected: adapter === 'web' }, - { name: 'Slack', connected: false }, - { name: 'Telegram', connected: false }, - { name: 'Discord', connected: false }, - { name: 'GitHub', connected: false }, + { name: 'Slack', connected: adapters?.slack ?? false }, + { name: 'Telegram', connected: adapters?.telegram ?? false }, + { name: 'Discord', connected: adapters?.discord ?? false }, + { name: 'GitHub', connected: adapters?.github ?? false }, ]; return ( @@ -641,7 +643,7 @@ export function SettingsPage(): React.ReactElement {
{configData && } - +