diff --git a/.env.example b/.env.example index 352fb40..627092d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,22 @@ SUMMARIZER_MODEL=claude-sonnet-4-6 SCROLLBACK_LINES=500 INTERMEDIATE_INTERVAL=10 +# Boot notification โ€” when set to 1 (default), LiteClaw sends a one-shot +# "๐Ÿš€ LiteClaw ready" message to your Telegram chat after the daemon +# finishes initializing. Set to 0 to disable. +BOOT_NOTIFY=1 + +# OpenClaw-style memory layout. Per-day transcripts, per-day markdown +# summaries, rolling strategic compact, and a synthesized startup primer +# all live under LITECLAW_DIR. +# LITECLAW_DIR=~/.liteclaw +# PRIMER_RECENT_TURNS=20 + +# Pin the cwd Claude Code launches in so `claude --continue` consistently +# resumes the same project bucket under ~/.claude/projects//. +# Defaults to $HOME if unset. +# CLAUDE_CWD=/Users/you + # File transfer STAGING_DIR=~/liteclaw-files diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bbd5c3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Something isn't working as expected +title: "[bug] " +labels: bug +assignees: '' +--- + +## What happened? + + + +## Environment + +- **OS**: +- **Python**: +- **tmux**: +- **Claude Code CLI**: +- **LiteClaw commit**: + +## How to reproduce + +1. ... +2. ... +3. ... + +## Expected vs actual + +- **Expected**: ... +- **Actual**: ... + +## Logs + +
+Relevant lines from /tmp/liteclaw_run.log + +``` +(paste log excerpt here โ€” redact BOT_TOKEN if present) +``` + +
+ + + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..282e658 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,34 @@ +--- +name: Feature request +about: Suggest an improvement or new capability +title: "[feat] " +labels: enhancement +assignees: '' +--- + +## The problem + + + +## Proposed solution + + + +## Alternatives considered + + + +## Scope / impact + +- [ ] Config-only (new env var, toggle) +- [ ] Single-file Python change in `liteclaw.py` +- [ ] Touches shell scripts (`start.sh`, `setup.sh`, `bin/liteclaw`) +- [ ] Touches storage layout (`~/.liteclaw/` or `~/.claude/projects/...`) +- [ ] New Telegram command +- [ ] Needs external integration (API proxy, bot webhook, etc.) + +## Additional context + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9288688 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main, feat/*] + pull_request: + branches: [main] + +jobs: + syntax-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Python syntax check (liteclaw.py) + run: python -c "import ast; ast.parse(open('liteclaw.py').read()); print('liteclaw.py syntax OK')" + + - name: Bash syntax check (shell entry points) + run: | + bash -n setup.sh + bash -n start.sh + bash -n bin/liteclaw + echo "bash syntax OK" + + - name: Install requirements (import check) + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + python -c " + import importlib + for mod in ('httpx', 'telegram', 'dotenv', 'apscheduler'): + importlib.import_module(mod) + print(f'import {mod}: OK') + " + + - name: Static import check on liteclaw.py + run: | + # `python -c "import liteclaw"` would try to spin up the bot; we only + # want to verify the module parses and top-level imports resolve. + python -c " + import ast, sys + src = open('liteclaw.py').read() + tree = ast.parse(src) + top_imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))] + print(f'{len(top_imports)} top-level imports parse cleanly') + " + + lint-ruff: + runs-on: ubuntu-latest + continue-on-error: true # linting is advisory; don't fail the build while codebase catches up + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check liteclaw.py --output-format=github || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6a4f9..f6a5dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## v0.6.1 (2026-04-23) โ€” Daemon-restart resilience, /tell-summarizer, community hygiene + +### Added +- **Daemon-restart resilience** โ€” user replies no longer disappear when the daemon is restarted mid-poll. + - `~/.liteclaw/pending_replies.json` records every user message whose placeholder ("โณ ์ž‘์—… ์ค‘โ€ฆ") was sent but not yet answered (user_msg_id, placeholder_msg_id, chat_id, jsonl offset, sent timestamp, target). + - `_resume_pending_on_boot` runs from `Application.post_init`. For each pending entry it either (a) reads the session jsonl and edits the placeholder with the now-completed answer, (b) spawns a background task to finish polling, or (c) marks the placeholder as "์žฌ๊ธฐ๋™์œผ๋กœ ์œ ์‹ค๋จ" if older than `PENDING_MAX_AGE_SEC` (default 900 s). + - `run_polling` now passes `stop_signals=(SIGINT, SIGTERM)` explicitly so `pkill liteclaw` triggers PTB's graceful drain. Hard kills / crashes are covered by the persistent-pending path. + - New env: `PENDING_MAX_AGE_SEC` (default 900). +- **`/tell-summarizer` command** (alias `/tellsum`) โ€” runtime control over the summarizer system prompt. `/tellsum ` appends, `/tellsum show` displays, `/tellsum clear` resets. Persists in-memory only. +- **Community hygiene** โ€” `CONTRIBUTING.md`, `.github/workflows/ci.yml` (AST parse + `bash -n` + requirements import smoke + advisory ruff lint), `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`, shields.io badges in both READMEs. + +### Changed +- **`SUMMARIZE_PROMPT`** gained a **PRESERVE-AS-IS** block โ€” numbered / lettered option lists ("B / C / D ์„ ํƒ") must reach Telegram verbatim. The summarizer previously kept "Pick one" while dropping the option bodies. + +--- + +## v0.6.0 (2026-04-20 โ†’ 2026-04-21) โ€” jsonl response path, session memory, global CLI, cron hardening + +(Merged via https://github.com/breaktheready/liteclaw/pull/1) + +### Added +- **Global `liteclaw` CLI** โ€” `liteclaw start|stop|restart|status|logs|attach` from any directory. `setup.sh` installs a symlink at `~/.local/bin/liteclaw`. +- **Pinned Claude Code session via `--session-id `** โ€” `start.sh` allocates / adopts a stable UUID in `~/.liteclaw/sessions.json.liteclaw_session_id` and always resumes the same conversation, unaffected by other Claude Code windows in the same cwd. +- **JSONL response path** (Phase A) โ€” responses are lifted from Claude Code's own session log (`~/.claude/projects//.jsonl`) instead of scraping the tmux pane. No ANSI chrome, no scrollback truncation, no summarizer over-compression. Automatic fallback to pane-scrape on jsonl failure. +- **OpenClaw-style memory layout** (`~/.liteclaw/`) โ€” per-day transcripts, daily markdown digests (LLM-compacted), rolling strategic summary, startup primer. Legacy `~/.liteclaw-history.jsonl` auto-migrates. +- **Compact session-id alias (`sid`)** in transcripts โ€” integer index into `sessions.json.history[]`, ~70 % shorter per row than embedding the full UUID. +- **`/recall session [uuid]`** โ€” session-scoped recall. +- **Boot-ready Telegram ping** โ€” one-shot "๐Ÿš€ LiteClaw ready" after init, rate-limited to 5 min. +- **`~/.liteclaw/cron-error-capture.md`** โ€” forensic log of failed cron runs (job config + pane snapshot). + +### Fixed +- **`is_idle_prompt` false-positive** on Claude Code UI chrome ("ยท Claude Max", "ยท Share Claude Codeโ€ฆ") that jammed the cron busy-wait at 120 s. Removed `ยท` and `*` from `_SPINNER_CHARS`, anchored `_ACTIVITY_PATTERNS` to line start. +- **Cron trust prompt** auto-accept in `_run_cron_job` for unattended project dirs. +- **APScheduler weekday indexing** โ€” `1-5` was Tue-Sat instead of Mon-Fri. +- **Mid-poll status edits** leaking "Thinking (Crystalizing)โ€ฆ" into Telegram. Gated behind `SHOW_POLLING_STATUS` (off by default). +- **`_followup_edit` overwriting delivered messages** with stale status edits when jsonl path had already returned a complete turn. Now skipped when `_jsonl_delivered_complete` is set. + +### Changed +- Placeholder text "๐Ÿ“ค Sent. Waiting for responseโ€ฆ" โ†’ "โณ ์ž‘์—… ์ค‘โ€ฆ". +- `setup.sh` plants sensible `~/.tmux.conf` defaults when missing (mouse on, history-limit 50000). + +--- + ## v0.5.0 (2026-04-17) โ€” CLI Mirror, Draft Streaming, Reasoning Lane & Skill System ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 642176a..b602fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,9 +250,10 @@ Ensures response delivery even when infrastructure is degraded. ## Auto-Recovery Mechanisms ### API Proxy Recovery -- On Tier 1 failure: runs `docker compose up -d` in the proxy directory +- On Tier 1 failure: runs `docker compose up -d` in the proxy directory (Linux hosts) - Retries connection after restart - Notifies user via Telegram on successful recovery +- **macOS note**: proxy runs as LaunchAgent, not Docker โ€” recovery would no-op. launchd's `KeepAlive` handles crash restarts automatically. See [MAC-OPS.md](./MAC-OPS.md). ### Session Auth Recovery (401) - Detects "401" + auth keywords in tmux pane output @@ -350,10 +351,11 @@ Direct `tmux send-keys -l` crashes on quotes, newlines, and special characters. ### Infrastructure: API Proxy LiteClaw's Tier 1 summarizer depends on an OpenAI-compatible API proxy. -- If down: auto-recovery attempts `docker compose up -d` +- If down: auto-recovery attempts `docker compose up -d` (Linux/auto-recovery path only) - Manual check: `curl -s http://localhost:3456/v1/models` -- Recommended proxy: [mattschwen/claude-max-api-proxy](https://github.com/mattschwen/claude-max-api-proxy) โ€” Docker compose, reuses Claude Max subscription +- Recommended proxy: [mattschwen/claude-max-api-proxy](https://github.com/mattschwen/claude-max-api-proxy) โ€” reuses Claude Max subscription - **Degraded without proxy** โ€” Tier 2/3 still work but quality drops +- **macOS**: Docker cannot access the user keychain where Claude stores OAuth tokens. Run the proxy as a LaunchAgent instead โ€” see [MAC-OPS.md](./MAC-OPS.md). ### Startup Sequence diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96fe0a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing to LiteClaw + +Thanks for considering a contribution. LiteClaw is a small, opinionated +single-file Python bridge โ€” changes should match that shape. + +## Ground rules + +- **Keep the core a single file.** `liteclaw.py` intentionally ships as one + file so operators can read / fork / deploy with zero ceremony. Factor into + separate modules only when the piece is genuinely standalone (e.g., a new + executable under `bin/` or an isolated helper). +- **No new Anthropic API key.** The project's reason-for-being is "reuse + your existing Claude Code CLI session." A patch that introduces a hard + dependency on `ANTHROPIC_API_KEY` or a specific vendor SDK will be closed. +- **Fallbacks are non-negotiable.** Anything that relies on an external + service (summarizer proxy, jsonl path, pyannote, etc.) must degrade + gracefully, not crash, when the external piece is unavailable. +- **Prefer environment variables over new CLI flags.** LiteClaw is + configured via `.env`. New knobs should land in `.env.example` with a + one-line description and a sane default. + +## Project structure + +``` +liteclaw/ +โ”œโ”€โ”€ liteclaw.py # core daemon (~5k lines, single file) +โ”œโ”€โ”€ start.sh # tmux session + session-id pin +โ”œโ”€โ”€ setup.sh # one-shot install (deps, venv, CLI symlink) +โ”œโ”€โ”€ bin/liteclaw # global CLI dispatcher +โ”œโ”€โ”€ .env.example # all configuration knobs +โ”œโ”€โ”€ README.md / README_KO.md # user docs +โ”œโ”€โ”€ MAC-OPS.md # macOS proxy/launchd notes +โ””โ”€โ”€ DEVNOTES.md # NOT shipped โ€” session-scoped devnotes (gitignored) +``` + +## Local sanity checks + +Before opening a PR run at least: + +```bash +python3 -c "import ast; ast.parse(open('liteclaw.py').read()); print('OK')" +bash -n setup.sh start.sh bin/liteclaw +``` + +The GitHub Actions workflow `.github/workflows/ci.yml` runs the same checks +plus a dependency import test on every push / PR. + +## Commit style + +- Small, logical commits. Split a feature from refactors from docs. +- Title prefix: `[feat]`, `[fix]`, `[docs]`, `[chore]`, `[refactor]`. +- Body: wrap at ~80 chars, explain the **why** and cite line numbers for + non-obvious changes. + +## Telegram-side behavior changes + +If your change affects what ends up in the user's Telegram chat (summarizer +prompt, follow-up edits, jsonl path, etc.), include a short "user-visible +diff" section in the PR description โ€” ideally a before/after screenshot or +transcript snippet. Regressions in Telegram UX have been subtle and hard to +notice in code review alone. + +## Issues and discussion + +- Bug report โ†’ [template](.github/ISSUE_TEMPLATE/bug_report.md). Please + include `/tmp/liteclaw_run.log` excerpts (redact `BOT_TOKEN`). +- Feature request โ†’ [template](.github/ISSUE_TEMPLATE/feature_request.md). + Concrete proposals move faster than abstract wishes. + +## Security + +If you find a security issue (credential leak, RCE surface, etc.), please +do NOT open a public issue. Contact the author directly via GitHub +(@breaktheready) with details. + +## Release flow + +Maintainer-only: + +1. Merge PRs into `main`. +2. Tag `vX.Y.Z` on `main`. +3. GitHub Release with notes summarizing the "What's new in vX.Y" section + from the README. + +Thanks for making LiteClaw sharper. diff --git a/MAC-OPS.md b/MAC-OPS.md new file mode 100644 index 0000000..df25fbb --- /dev/null +++ b/MAC-OPS.md @@ -0,0 +1,104 @@ +# macOS Operations Guide + +Platform-specific guidance for running LiteClaw + claude-max-api-proxy on macOS. + +--- + +## Why Not Docker for the Proxy on macOS + +Docker containers on macOS **cannot access the user keychain**, where the `claude` CLI stores OAuth tokens. The container falls back to reading `~/.claude/.credentials.json` mounted read-only, which goes stale within hours as the host CLI refreshes tokens. + +**Symptoms** when Docker is used on macOS: +- `/v1/models` returns `{"data":[]}` +- `/health` shows rising `consecutiveAuthFailures` +- Chat requests fail with `401 Invalid authentication credentials` +- LiteClaw silently degrades to Tier 2 (slow agent-session summarizer) + +**Correct setup**: run the proxy natively as a LaunchAgent under your user account so the embedded `claude` CLI can read live keychain credentials. The proxy repo ships an authoritative guide at `claude-max-api-proxy/docs/macos-setup.md`. + +--- + +## Quick Setup + +```bash +# 1. Build the proxy from source (Node.js โ‰ฅ 22 required) +cd ~/projects/claude-max-api-proxy +npm install +npm run build + +# 2. Authenticate Claude CLI on the host (writes to keychain) +claude auth login + +# 3. Install the LaunchAgent (follow proxy's docs/macos-setup.md) +# โ€” generates the plist with paths resolved from $(pwd) and $(command -v node) +# โ€” bootstraps with launchctl bootstrap "gui/$(id -u)" ... + +# 4. Verify +curl -s http://127.0.0.1:3456/health | python3 -m json.tool +curl -s http://127.0.0.1:3456/v1/models +``` + +After this, both the `auto-recovery` path inside `liteclaw.py` (kicks the LaunchAgent on Tier 1 failure) and `start.sh` (the launcher) will detect macOS and behave correctly. + +--- + +## Management + +```bash +# Status +launchctl print "gui/$(id -u)/com.claude-max-api-proxy" | grep -E '(state|pid|runs)' + +# Restart (picks up new build) +launchctl kickstart -k "gui/$(id -u)/com.claude-max-api-proxy" + +# Stop +launchctl bootout "gui/$(id -u)/com.claude-max-api-proxy" + +# Logs +tail -f ~/Library/Logs/claude-max-api-proxy.log +tail -f ~/Library/Logs/claude-max-api-proxy.err.log +``` + +--- + +## Launching LiteClaw + Claude Code together + +```bash +bash ~/projects/liteclaw/start.sh # detached +bash ~/projects/liteclaw/start.sh --attach # detached + attach to claude pane +``` + +`start.sh` is idempotent (re-running is a no-op when already up), starts the tmux `claude` pane first, waits for the prompt (handling the "trust this folder" dialog automatically), then starts LiteClaw so it has somewhere to inject messages. + +--- + +## Troubleshooting + +### Symptom: LiteClaw summaries are slow, Tier 2 doing the work +Tier 1 proxy is down or returning errors. Check: +```bash +curl -s http://127.0.0.1:3456/health \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('auth:', d['auth']['loggedIn'], 'models:', d['models']['available'])" +``` +If `auth.loggedIn=false` or `models=[]`: confirm `claude auth status` works on the host. If host is fine but proxy still fails, the deployment likely reverted to Docker โ€” switch back to LaunchAgent. + +### Symptom: `localhost` works in the browser/curl but LiteClaw still gets 503/401 +A ghost Docker container or other process may be listening on `:3456` over IPv6 while the LaunchAgent listens on IPv4. macOS resolves `localhost` to `::1` first. +```bash +lsof -iTCP:3456 -sTCP:LISTEN # expect a single 'node' process +docker ps | grep claude-max # remove any container holding the port +docker stop claude-max-proxy && docker rm claude-max-proxy +``` +Belt-and-suspenders: set `SUMMARIZER_URL=http://127.0.0.1:3456/v1` in `.env` to force IPv4. + +### Symptom: `launchctl print` shows a high `runs` count +Port conflict or repeated crash. Investigate logs and free the port: +```bash +lsof -iTCP:3456 -sTCP:LISTEN +launchctl bootout "gui/$(id -u)/com.claude-max-api-proxy" +pkill -f 'claude-max-api-proxy/dist/server/standalone.js' +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.claude-max-api-proxy.plist" +``` + +### Symptom: `~/.claude/.credentials.json` shows expired `expiresAt` +Normal on macOS โ€” the CLI uses keychain; the file is a legacy artifact. Don't manually edit it. diff --git a/README.md b/README.md index 55d3c2c..96272d5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # LiteClaw +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#platform-support) +[![CI](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml) +[![GitHub stars](https://img.shields.io/github/stars/breaktheready/liteclaw?style=social)](https://github.com/breaktheready/liteclaw/stargazers) + Control Claude Code CLI remotely via Telegram. No API key needed. [ํ•œ๊ตญ์–ด](README_KO.md) @@ -47,6 +53,23 @@ Unlike tools that call Claude's API directly (which means extra costs), LiteClaw --- +## What's new in v0.6 (April 2026) + +Focused on reliability, delivery quality, and agent continuity: + +- **Global `liteclaw` CLI** โ€” `liteclaw start|stop|restart|status|logs|attach` from any directory. `setup.sh` installs a symlink in `~/.local/bin/liteclaw`. +- **Pinned Claude Code session (`--session-id `)** โ€” `start.sh` allocates / adopts a stable UUID and always resumes the same conversation, even when other Claude Code windows are open in the same cwd. +- **Structured JSONL response path** โ€” responses are now lifted from Claude Code's own session log (`~/.claude/projects//.jsonl`) instead of scraping the tmux pane. No more ANSI chrome, no scroll-back truncation, no summarizer over-compression on long answers. Automatic fallback to the pane path if jsonl is unavailable. +- **OpenClaw-style memory layout** (`~/.liteclaw/`) โ€” per-day transcripts, daily markdown digests (LLM-compacted), rolling strategic summary, and a startup primer assembled from recent + strategic context. Legacy `~/.liteclaw-history.jsonl` auto-migrates on first boot. +- **Boot-ready Telegram ping** โ€” one-shot "๐Ÿš€ LiteClaw ready" after init with resume state + primer size. Rate-limited to avoid dev-churn spam (`BOOT_NOTIFY`). +- **Cron hardening** โ€” trust-prompt auto-accept, `is_idle_prompt` false-positive fix (Claude's UI chrome no longer jams the busy-wait), 300 s tolerance window with a stable-prompt fallback, and failures are captured with full pane snapshot to `~/.liteclaw/cron-error-capture.md` for later review. +- **`/recall session [uuid]`** โ€” session-scoped conversation recall. Transcripts carry a compact integer alias (`sid`) into `sessions.json.history[]` so every row is ~70 % smaller than embedding the full UUID. +- **Quieter Telegram UX** โ€” mid-poll status edits off by default (`SHOW_POLLING_STATUS=0`); completed jsonl turns skip the follow-up monitor so the final message is never overwritten with a stale status edit. + +See `DEVNOTES.md` for the story behind these changes and the parking-lot for related ideas. + +--- + ## Prerequisites - **Python** 3.10+ @@ -60,6 +83,14 @@ Unlike tools that call Claude's API directly (which means extra costs), LiteClaw - The default `SUMMARIZER_URL` is `http://localhost:3456/v1` (matches the recommended proxy's default port) - **(Optional) Docker** โ€” only needed if your API proxy runs in a container +### Platform support + +| Platform | Status | Notes | +|---|---|---| +| Linux | โœ… Primary target | Docker proxy works directly | +| macOS | โœ… Supported | Run the proxy as a **LaunchAgent** instead of Docker โ€” Docker can't read the keychain. See [MAC-OPS.md](./MAC-OPS.md). Use [`start.sh`](./start.sh) to launch Claude Code + LiteClaw together. | +| Windows | โš ๏ธ Not native | LiteClaw depends on tmux. Run under **WSL** (Ubuntu/Debian) and follow the Linux path. Native PowerShell/cmd is not supported. | + --- ## Quick Start @@ -110,6 +141,24 @@ python3 liteclaw.py Send `/start` to your Telegram bot to confirm it is working. +### One-command launch (`start.sh` or global `liteclaw`) + +`setup.sh` symlinks a global `liteclaw` dispatcher into `~/.local/bin/`, so once installed you can run from any directory: + +```bash +liteclaw start # bring up Claude Code (tmux 'claude') + LiteClaw daemon +liteclaw start --attach # ... and attach to the claude pane after +liteclaw stop # tear both down +liteclaw restart # stop + start +liteclaw status # tmux sessions, daemon pid, dashboard port +liteclaw logs [-f] # tail /tmp/liteclaw_run.log +liteclaw attach # tmux attach -t claude +``` + +The underlying `bash ./start.sh [--attach]` still works for direct invocation. It's idempotent โ€” re-running is a no-op when both are already up. Order is enforced (Claude Code first, prompt-readiness wait, then LiteClaw) so the bridge always has a target. The "trust this folder" dialog is auto-accepted. + +**Session pinning**: `start.sh` allocates (or adopts) a stable UUID on first run and stores it in `~/.liteclaw/sessions.json`, then launches `claude --session-id `. Every subsequent `liteclaw start` resumes the *same* conversation โ€” unaffected by other Claude Code windows you may open in the same cwd. + --- ## Configuration @@ -129,8 +178,14 @@ All settings are controlled via `.env`. Copy `.env.example` and edit as needed. | `EXTRA_PROMPT_PATTERNS` | (empty) | Comma-separated regex patterns for custom prompt detection | | `PROXY_DIR` | `~/max_api_proxy` | API proxy directory for auto-recovery (`docker compose up -d`) | | `DASHBOARD_PORT` | `7777` | Web dashboard port (set `0` to disable) | -| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | Conversation history location | +| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | Legacy single-file conversation history (still written for back-compat with `/recall`) | | `HISTORY_RECALL_LIMIT` | `50` | Max entries returned by `/recall` | +| `BOOT_NOTIFY` | `1` | Send a one-shot "LiteClaw ready" Telegram ping after init. `0` to disable. | +| `LITECLAW_DIR` | `~/.liteclaw` | Root of the OpenClaw-style memory layout (`transcripts/`, `memory/`, `sessions.json`, `primer.md`) | +| `CLAUDE_CWD` | `$HOME` | Pinned working directory where `start.sh` launches Claude Code. Used to locate the session JSONL under `~/.claude/projects//`. | +| `PRIMER_RECENT_TURNS` | `20` | Turns pulled from recent history when building `primer.md` | +| `USE_JSONL_RESPONSE` | `1` | Prefer Claude Code's session JSONL as the response source (falls back to pane scraping on failure). `0` to force pane path. | +| `SHOW_POLLING_STATUS` | `0` | Emit intermediate "Workingโ€ฆ" status edits to Telegram while Claude works. Off by default โ€” these were the source of garbled mid-answer updates. | --- diff --git a/README_KO.md b/README_KO.md index 7591f67..589aacf 100644 --- a/README_KO.md +++ b/README_KO.md @@ -1,5 +1,11 @@ # LiteClaw +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#ํ”Œ๋žซํผ-์ง€์›) +[![CI](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/breaktheready/liteclaw/actions/workflows/ci.yml) +[![GitHub stars](https://img.shields.io/github/stars/breaktheready/liteclaw?style=social)](https://github.com/breaktheready/liteclaw/stargazers) + ํ…”๋ ˆ๊ทธ๋žจ์œผ๋กœ Claude Code CLI๋ฅผ ์›๊ฒฉ ์ œ์–ด. ์ถ”๊ฐ€ API ํ‚ค ๋ถˆํ•„์š”. [English](README.md) @@ -43,6 +49,21 @@ Claude API๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ๋„๊ตฌ๋“ค๊ณผ ๋‹ฌ๋ฆฌ (= ์ถ”๊ฐ€ ๋น„์šฉ), LiteCla - **์ž๋™ ๋ณต๊ตฌ** โ€” API ํ”„๋ก์‹œ ๋‹ค์šดํƒ€์ž„์„ ์ž๋™ ๊ฐ์ง€ํ•˜๊ณ  ๋ณต๊ตฌํ•ฉ๋‹ˆ๋‹ค. 401 ์—๋Ÿฌ ์‹œ Claude Code ์„ธ์…˜์„ ์ž๋™ ์žฌ์ธ์ฆํ•˜๊ณ , ๋ณต๊ตฌ ์™„๋ฃŒ ์‹œ ํ…”๋ ˆ๊ทธ๋žจ์œผ๋กœ ์•Œ๋ฆผ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. - **ํ†ตํ•ฉ ์•Œ๋ฆผ** โ€” ๋ชจ๋“  ํ…”๋ ˆ๊ทธ๋žจ ์•Œ๋ฆผ์ด ๋‹จ์ผ `notify.py` ๋ชจ๋“ˆ์„ ํ†ตํ•ด ์š”์•ฝ๊ธฐ๋ฅผ ๊ฑฐ์ณ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์š”์•ฝ๊ธฐ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ ์›๋ณธ ์ถœ๋ ฅ์œผ๋กœ ์ž๋™ ์ „ํ™˜๋ฉ๋‹ˆ๋‹ค. +## v0.6 ์—…๋ฐ์ดํŠธ (2026๋…„ 4์›”) + +์•ˆ์ •์„ฑ, ์ „๋‹ฌ ํ’ˆ์งˆ, ์—์ด์ „ํŠธ ์—ฐ์†์„ฑ์— ์ง‘์ค‘: + +- **์ „์—ญ `liteclaw` CLI** โ€” ์–ด๋А ๋””๋ ‰ํ† ๋ฆฌ์—์„œ๋“  `liteclaw start|stop|restart|status|logs|attach`. `setup.sh`๊ฐ€ `~/.local/bin/liteclaw`์— ์‹ฌ๋งํฌ ์„ค์น˜. +- **Claude Code ์„ธ์…˜ ๊ณ ์ • (`--session-id `)** โ€” `start.sh`๊ฐ€ ์•ˆ์ •์ ์ธ UUID๋ฅผ ๋ฐœ๊ธ‰/์ฑ„ํƒํ•ด์„œ `~/.liteclaw/sessions.json`์— ์ €์žฅ. ๊ฐ™์€ cwd์—์„œ ๋‹ค๋ฅธ Claude Code ์ฐฝ์„ ์—ด์–ด๋„ LiteClaw๋Š” ํ•ญ์ƒ ์ž๊ธฐ ์„ธ์…˜์— ์ด์–ด๋ถ™์Œ. +- **๊ตฌ์กฐํ™”๋œ JSONL ์‘๋‹ต ๊ฒฝ๋กœ** โ€” tmux ํ™”๋ฉด์„ ๊ธ๋Š” ๋Œ€์‹  Claude Code ๋ณธ์ธ์˜ ์„ธ์…˜ ๋กœ๊ทธ(`~/.claude/projects//.jsonl`)๋ฅผ ์ฝ์–ด ์‘๋‹ต์„ ์ถ”์ถœ. ANSI ๋…ธ์ด์ฆˆ, ์Šคํฌ๋กค๋ฐฑ ์ž˜๋ฆผ, ์š”์•ฝ๊ธฐ ๊ณผ์••์ถ• ๋ชจ๋‘ ์ œ๊ฑฐ. jsonl ์‹คํŒจ ์‹œ ๊ธฐ์กด pane ๊ฒฝ๋กœ๋กœ ์ž๋™ ํด๋ฐฑ. +- **OpenClaw ์Šคํƒ€์ผ ๋ฉ”๋ชจ๋ฆฌ ๋ ˆ์ด์•„์›ƒ** (`~/.liteclaw/`) โ€” ์ผ๋ณ„ transcript, ์ผ๋ณ„ markdown ๋‹ค์ด์ œ์ŠคํŠธ(LLM ์••์ถ•), ์žฅ๊ธฐ ์ „๋žต ์š”์•ฝ, ๊ธฐ๋™ ์‹œ ํ”„๋ผ์ด๋จธ ์ž๋™ ํ•ฉ์„ฑ. ๊ธฐ์กด `~/.liteclaw-history.jsonl`์€ ์ฒซ ๋ถ€ํŒ… ๋•Œ ์ž๋™ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜. +- **๋ถ€ํŒ… ์™„๋ฃŒ ํ…”๋ ˆ๊ทธ๋žจ ์•Œ๋ฆผ** โ€” init ์™„๋ฃŒ ํ›„ "๐Ÿš€ LiteClaw ready" + resume ์ƒํƒœ + primer ํฌ๊ธฐ ์ „์†ก. 5๋ถ„ ๋‚ด ์žฌ๊ธฐ๋™ ์‹œ ์ž๋™ ์ƒ๋žต(`BOOT_NOTIFY`). +- **ํฌ๋ก  ๋‚ด๊ตฌ์„ฑ ๊ฐ•ํ™”** โ€” trust ํ”„๋กฌํ”„ํŠธ ์ž๋™ ์ˆ˜๋ฝ, `is_idle_prompt` ์˜คํƒ ์ˆ˜์ •(Claude UI chrome์— ๊ฑธ๋ ค 120์ดˆ ํƒ€์ž„์•„์›ƒ ๋‚˜๋˜ ๋ฒ„๊ทธ), 300์ดˆ ํ—ˆ์šฉ์ฐฝ๊ณผ stable-prompt ํด๋ฐฑ, ์‹คํŒจ ์‹œ pane ์Šค๋ƒ…์ƒท ํฌํ•จ ์ „์ฒด ๊ธฐ๋ก์ด `~/.liteclaw/cron-error-capture.md`์— ๋ˆ„์ . +- **`/recall session [uuid]`** โ€” ํ˜„์žฌ ์„ธ์…˜ ๋˜๋Š” ํŠน์ • ์„ธ์…˜ ๋‚ด ๋Œ€ํ™”๋งŒ ์š”์•ฝ/๊ฒ€์ƒ‰. ๊ฐ transcript ์—”ํŠธ๋ฆฌ์—” `sessions.json.history[]` ๋ฐฐ์—ด ์ธ๋ฑ์Šค(`sid`)๋งŒ ์‹ฌ์–ด์„œ ํ’€ UUID ๋Œ€๋น„ ~70% ์งง์Œ. +- **์กฐ์šฉํ•ด์ง„ ํ…”๋ ˆ๊ทธ๋žจ UX** โ€” ํด๋ง ์ค‘๊ฐ„ ์ƒํƒœ ํŽธ์ง‘ ๊ธฐ๋ณธ OFF(`SHOW_POLLING_STATUS=0`); jsonl๋กœ ์™„๊ฒฐ๋œ ํ„ด์€ follow-up ๋ชจ๋‹ˆํ„ฐ๋ฅผ ๊ฑด๋„ˆ๋›ฐ์–ด ๋ฐฐ๋‹ฌ๋œ ๋ฉ”์‹œ์ง€๊ฐ€ ์ƒํƒœ ํŽธ์ง‘์œผ๋กœ ๋ฎ์–ด์“ฐ์ด์ง€ ์•Š์Œ. + +๋ณ€ํ™” ๋ฐฐ๊ฒฝ๊ณผ ํŒŒํ‚น ๋กœํŠธ ์•„์ด๋””์–ด๋Š” `DEVNOTES.md` ์ฐธ์กฐ. + ## ๋น ๋ฅธ ์‹œ์ž‘ ### ์ž๋™ ์„ค์น˜ (๊ถŒ์žฅ) @@ -98,6 +119,29 @@ TMUX_TARGET=claude:1 #### ์‹คํ–‰ +**๊ถŒ์žฅ: ์ „์—ญ `liteclaw` ๋ช…๋ น** โ€” `setup.sh`๊ฐ€ `~/.local/bin/liteclaw`์— ์‹ฌ๋งํฌ๋ฅผ ๋งŒ๋“ค์–ด๋‘๋ฏ€๋กœ ์–ด๋А ๋””๋ ‰ํ† ๋ฆฌ์—์„œ๋“ : + +```bash +liteclaw start # Claude Code(tmux 'claude') + LiteClaw ๋ฐ๋ชฌ ๊ธฐ๋™ +liteclaw start --attach # ๊ทธ๋ฆฌ๊ณ  claude pane์— attach +liteclaw stop # ๋‘˜ ๋‹ค ์ข…๋ฃŒ +liteclaw restart # stop + start +liteclaw status # tmux ์„ธ์…˜, ๋ฐ๋ชฌ pid, ๋Œ€์‹œ๋ณด๋“œ ํฌํŠธ ์š”์•ฝ +liteclaw logs [-f] # /tmp/liteclaw_run.log tail +liteclaw attach # tmux attach -t claude +``` + +**์ง์ ‘ ์‹คํ–‰ (๋Œ€์ฒด)**: + +```bash +bash ./start.sh # ์œ„์™€ ๋™์ผ, ์ €์žฅ์†Œ ์•ˆ์—์„œ๋งŒ ๋™์ž‘ +bash ./start.sh --attach +``` + +**์„ธ์…˜ ๊ณ ์ •**: `start.sh`๊ฐ€ ์ฒซ ์‹คํ–‰ ์‹œ ์•ˆ์ • UUID ํ•˜๋‚˜๋ฅผ ๋ฐœ๊ธ‰ํ•˜๊ฑฐ๋‚˜ ๊ธฐ์กด ์„ธ์…˜์„ ์ฑ„ํƒํ•ด์„œ `~/.liteclaw/sessions.json`์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ํ›„ `liteclaw start` ํ•  ๋•Œ๋งˆ๋‹ค `claude --session-id `๋กœ ๊ฐ™์€ ๋Œ€ํ™”๋ฅผ ๋ณต๊ท€ โ€” ๊ฐ™์€ cwd์— ๋‹ค๋ฅธ Claude Code ์ฐฝ์ด ์žˆ์–ด๋„ LiteClaw๋Š” ์ž๊ธฐ ์„ธ์…˜ ๊ณ ์ˆ˜. + +์ˆ˜๋™ ์„ค์ •์ด ํ•„์š”ํ•˜๋ฉด ์›๋ž˜ ๋ฐฉ์‹๋„ ๊ทธ๋Œ€๋กœ ๋™์ž‘: + ```bash # ํ„ฐ๋ฏธ๋„ 1: Claude Code ์‹œ์ž‘ tmux new-session -s claude 'claude --dangerously-skip-permissions' @@ -124,8 +168,14 @@ python3 liteclaw.py | `EXTRA_PROMPT_PATTERNS` | (๋น„์–ด์žˆ์Œ) | ์ปค์Šคํ…€ ํ”„๋กฌํ”„ํŠธ ๊ฐ์ง€์šฉ ์ •๊ทœ์‹ (์‰ผํ‘œ ๊ตฌ๋ถ„) | | `PROXY_DIR` | `~/max_api_proxy` | API ํ”„๋ก์‹œ ๋””๋ ‰ํ† ๋ฆฌ (์ž๋™ ๋ณต๊ตฌ์šฉ `docker compose up -d`) | | `DASHBOARD_PORT` | `7777` | ์›น ๋Œ€์‹œ๋ณด๋“œ ํฌํŠธ (`0`์œผ๋กœ ๋น„ํ™œ์„ฑํ™”) | -| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | ๋Œ€ํ™” ๊ธฐ๋ก ํŒŒ์ผ ์œ„์น˜ | +| `HISTORY_FILE` | `~/.liteclaw-history.jsonl` | ๋ ˆ๊ฑฐ์‹œ ๋‹จ์ผ ํŒŒ์ผ ๋Œ€ํ™” ๊ธฐ๋ก (`/recall` back-compat์šฉ์œผ๋กœ ๊ณ„์† ๊ธฐ๋ก) | | `HISTORY_RECALL_LIMIT` | `50` | `/recall` ์ตœ๋Œ€ ๋ฐ˜ํ™˜ ๊ฐœ์ˆ˜ | +| `BOOT_NOTIFY` | `1` | ๋ฐ๋ชฌ ๊ธฐ๋™ ์™„๋ฃŒ ํ›„ "LiteClaw ready" ํ…”๋ ˆ๊ทธ๋žจ ์•Œ๋ฆผ (0์œผ๋กœ ๋น„ํ™œ์„ฑํ™”) | +| `LITECLAW_DIR` | `~/.liteclaw` | OpenClaw ์Šคํƒ€์ผ ๋ฉ”๋ชจ๋ฆฌ ๋ฃจํŠธ (`transcripts/`, `memory/`, `sessions.json`, `primer.md`) | +| `CLAUDE_CWD` | `$HOME` | `start.sh`๊ฐ€ Claude Code๋ฅผ ๋„์šฐ๋Š” ๊ณ ์ • cwd. ์„ธ์…˜ JSONL ์œ„์น˜(`~/.claude/projects//`) ํ•ด์„์— ์‚ฌ์šฉ | +| `PRIMER_RECENT_TURNS` | `20` | `primer.md` ํ•ฉ์„ฑ ์‹œ ์ตœ๊ทผ ์ด๋ ฅ์—์„œ ๋‹น๊ธธ turn ์ˆ˜ | +| `USE_JSONL_RESPONSE` | `1` | Claude Code ์„ธ์…˜ JSONL์„ ์‘๋‹ต ์†Œ์Šค๋กœ ์šฐ์„  ์‚ฌ์šฉ (์‹คํŒจ ์‹œ pane ์Šคํฌ๋ž˜ํ”„๋กœ ์ž๋™ ํด๋ฐฑ). `0`์ด๋ฉด pane ๊ฐ•์ œ | +| `SHOW_POLLING_STATUS` | `0` | Claude๊ฐ€ ์ž‘์—… ์ค‘์ผ ๋•Œ ์ค‘๊ฐ„ "Workingโ€ฆ" ์ƒํƒœ๋ฅผ ํ…”๋ ˆ๊ทธ๋žจ์— ๋ณด๋‚ผ์ง€ ์—ฌ๋ถ€. ๊ธฐ๋ณธ OFF โ€” ์ด๊ฒŒ ๋ฉ”์‹œ์ง€ ์ค‘๊ฐ„ ๋ฎ์–ด์“ฐ๊ธฐ์˜ ์›์ธ์ด์—ˆ์Œ | ## ํ…”๋ ˆ๊ทธ๋žจ ๋ช…๋ น์–ด diff --git a/bin/liteclaw b/bin/liteclaw new file mode 100755 index 0000000..a4a80f3 --- /dev/null +++ b/bin/liteclaw @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# liteclaw โ€” global CLI dispatcher. +# Resolves the LiteClaw repo via its own location (or $LITECLAW_HOME) and +# delegates to start.sh / tmux / log files. +# +# Usage: +# liteclaw start [--attach] bring up Claude Code + LiteClaw +# liteclaw stop tear down both tmux sessions +# liteclaw restart stop + start +# liteclaw status session + process + dashboard summary +# liteclaw logs [-f] tail /tmp/liteclaw_run.log +# liteclaw attach tmux attach -t claude +# liteclaw help this help + +set -euo pipefail + +# Resolve REPO root: $LITECLAW_HOME wins, otherwise infer from this script's path +# (handles symlink in ~/.local/bin/liteclaw โ†’ repo/bin/liteclaw). +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + LINK_DIR=$(cd -P "$(dirname "$SOURCE")" && pwd) + SOURCE=$(readlink "$SOURCE") + [[ $SOURCE != /* ]] && SOURCE="$LINK_DIR/$SOURCE" +done +SCRIPT_DIR=$(cd -P "$(dirname "$SOURCE")" && pwd) +REPO="${LITECLAW_HOME:-$(cd "$SCRIPT_DIR/.." && pwd)}" +LOG="/tmp/liteclaw_run.log" + +[ -f "$REPO/liteclaw.py" ] || { echo "โœ— liteclaw.py not found under $REPO (set LITECLAW_HOME)" >&2; exit 1; } + +cmd="${1:-help}" +shift || true + +case "$cmd" in + start) + exec bash "$REPO/start.sh" "$@" + ;; + + stop) + stopped=0 + if tmux has-session -t liteclaw 2>/dev/null; then + tmux kill-session -t liteclaw && echo "โœ“ killed tmux 'liteclaw'" + stopped=1 + fi + if pgrep -f "python.*liteclaw.py" >/dev/null; then + pkill -f "python.*liteclaw.py" && echo "โœ“ killed liteclaw.py process(es)" + stopped=1 + fi + if tmux has-session -t claude 2>/dev/null; then + tmux kill-session -t claude && echo "โœ“ killed tmux 'claude'" + stopped=1 + fi + [ "$stopped" -eq 0 ] && echo "(nothing was running)" + ;; + + restart) + "$0" stop || true + sleep 1 + exec "$0" start "$@" + ;; + + status) + echo "REPO: $REPO" + echo + echo "tmux sessions:" + if tmux list-sessions 2>/dev/null | grep -E '^(claude|liteclaw):'; then :; else + echo " (none of claude/liteclaw running)" + fi + echo + pid=$(pgrep -f "python.*liteclaw.py" | head -1 || true) + if [ -n "$pid" ]; then + echo "liteclaw.py: running (pid $pid)" + else + echo "liteclaw.py: not running" + fi + # Dashboard port (default 7777, configurable via .env). Tolerate missing + # key / missing .env without tripping `set -e` + pipefail. + port="" + if [ -f "$REPO/.env" ]; then + port=$( { grep -E '^DASHBOARD_PORT=' "$REPO/.env" || true; } | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | xargs) + fi + port=${port:-7777} + if [ "$port" != "0" ] && lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "dashboard: listening on :$port" + else + echo "dashboard: (port $port not listening)" + fi + echo + echo "log: $LOG" + ;; + + logs) + [ -f "$LOG" ] || { echo "(no log yet at $LOG)"; exit 0; } + if [ "${1:-}" = "-f" ]; then + exec tail -f "$LOG" + else + exec tail -n 80 "$LOG" + fi + ;; + + attach) + if tmux has-session -t claude 2>/dev/null; then + exec tmux attach -t claude + else + echo "โœ— no 'claude' tmux session โ€” run 'liteclaw start' first" >&2 + exit 1 + fi + ;; + + help|-h|--help) + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + ;; + + *) + echo "unknown command: $cmd" >&2 + echo "try: liteclaw help" >&2 + exit 2 + ;; +esac diff --git a/liteclaw.py b/liteclaw.py index fe1ac6c..a5b9b36 100644 --- a/liteclaw.py +++ b/liteclaw.py @@ -94,6 +94,40 @@ HISTORY_FILE = Path(os.environ.get("HISTORY_FILE", os.path.expanduser("~/.liteclaw-history.jsonl"))) HISTORY_RECALL_LIMIT = int(os.environ.get("HISTORY_RECALL_LIMIT", "50")) # max entries for /recall +# OpenClaw-style memory layout: per-day transcripts + per-day markdown summaries +# + rolling strategic compact + synthesized startup primer. +LITECLAW_DIR = Path(os.environ.get("LITECLAW_DIR", os.path.expanduser("~/.liteclaw"))) +LITECLAW_TRANSCRIPTS = LITECLAW_DIR / "transcripts" +LITECLAW_MEMORY = LITECLAW_DIR / "memory" +LITECLAW_STRATEGIC = LITECLAW_MEMORY / "strategic.md" +LITECLAW_PRIMER = LITECLAW_DIR / "primer.md" +LITECLAW_SESSIONS = LITECLAW_DIR / "sessions.json" +# Pending replies โ€” user messages whose placeholder was sent but the response +# wasn't delivered before the daemon stopped. Persisted so a restart can read +# the session jsonl, find the now-completed turn, and edit the placeholder +# in-place. See _record_pending / _resume_pending_on_boot. +LITECLAW_PENDING = LITECLAW_DIR / "pending_replies.json" +PENDING_MAX_AGE_SEC = int(os.environ.get("PENDING_MAX_AGE_SEC", "900")) # 15 min + +# Boot notification (sent to Telegram once after the bot is fully initialized) +BOOT_NOTIFY = os.environ.get("BOOT_NOTIFY", "1").lower() not in ("0", "false", "no", "off") + +# Claude Code working directory (where start.sh launches claude). Used to +# locate ~/.claude/projects// for resume-state detection. +CLAUDE_CWD = Path(os.environ.get("CLAUDE_CWD", os.path.expanduser("~"))) +PRIMER_RECENT_TURNS = int(os.environ.get("PRIMER_RECENT_TURNS", "20")) + +# JSONL-based response extraction: reads Claude Code's own session log at +# ~/.claude/projects//.jsonl for clean, +# structured responses instead of scraping the tmux pane (which leaks TUI +# chrome like "Thinking (Crystalizing)" and truncates long answers at the +# scroll-back boundary). Setting USE_JSONL_RESPONSE=0 disables this path. +USE_JSONL_RESPONSE = os.environ.get("USE_JSONL_RESPONSE", "1").lower() not in ("0", "false", "no", "off") +# Suppress mid-poll status edits while waiting for the final response โ€” they +# were the source of the "Thinking (Crystalizing)" garbled messages on +# Telegram. Users can re-enable via SHOW_POLLING_STATUS=1 for debugging. +SHOW_POLLING_STATUS = os.environ.get("SHOW_POLLING_STATUS", "0").lower() not in ("0", "false", "no", "off") + # v0.5.0 UX Overhaul โ€” OpenClaw/Hermes-inspired # F1 CLI Mirror MIRROR_ENABLED = os.environ.get("MIRROR_ENABLED", "false").lower() == "true" @@ -299,11 +333,16 @@ def is_idle_prompt(content: str) -> bool: "|Misting|Expanding|Parsing|Crafting|Focusing|Wondering|Pondering" "|Transfiguring" ) -# Primary: spinner char + any word = activity (future-proof against new labels) -# Fallback: explicit label list kept for documentation/reference -_SPINNER_CHARS = r"[โœปโœถโœฝโœขยทโ—*โ—โ—‘โ—’โ—“โ ‹โ ™โ นโ ธโ ผโ ดโ ฆโ งโ ‡โ ]" +# Spinner + capitalized word = activity. The spinner must sit at line start +# (after optional whitespace) โ€” otherwise Claude Code's permanent UI chrome +# triggers false positives like "Opus 4.7 (1M context) ยท Claude Max" and +# "[โœป] โ€ฆ ยท Share Claude Codeโ€ฆ", which used to keep is_idle_prompt() returning +# False forever and hang cron jobs at the 120s busy-wait. +# Middle dot `ยท` and `*` are excluded because they are routine separators, not +# actual spinner frames used by Claude Code. +_SPINNER_CHARS = r"[โœปโœถโœฝโœขโ—โ—โ—‘โ—’โ—“โ ‹โ ™โ นโ ธโ ผโ ดโ ฆโ งโ ‡โ ]" _ACTIVITY_PATTERNS = re.compile( - rf"{_SPINNER_CHARS}\s*[A-Z][a-z]{{2,}}" # spinner + capitalized word (e.g. โœข Transfiguring) + rf"^\s*{_SPINNER_CHARS}\s+[A-Z][a-z]{{2,}}" # line-anchored: โœข Transfiguring r"|\(thinking\)" ) @@ -520,6 +559,20 @@ def format_for_telegram(text: str) -> str: - If the output contains an error, highlight it clearly - Keep it concise but NEVER drop important content โ€” completeness over brevity +PRESERVE-AS-IS (never compress or drop): +- Numbered or lettered option lists presented for the user to choose from. + Examples: + "1. โ€ฆ 2. โ€ฆ 3. โ€ฆ" + "A) โ€ฆ B) โ€ฆ C) โ€ฆ" + "**B** โ€” โ€ฆ **C** โ€” โ€ฆ **D** โ€” โ€ฆ" + If the original asks "B/C/D ์ค‘ ๋ญ˜๋กœ?", "which one?", "๋ฒˆํ˜ธ ๊ณจ๋ผ", "pick one", + "์„ ํƒํ•ด์ค˜", "choose", etc., keep EVERY option line verbatim. Dropping the + option descriptions makes the message unanswerable via Telegram. +- Explicit questions directed at the user ("...ํ• ๊นŒ์š”?", "which should I โ€ฆ?", + "๊ฐ€? ์•„๋‹ˆ๋ฉด โ€ฆ?"). Keep them intact. +- Any line that starts with a choice marker ("[A]", "(B)", "1)", "- A:", etc.) + within a section that is clearly a choice menu. + Telegram formatting rules: - NO markdown tables (|---|) โ€” use bullet points: "ํ•ญ๋ชฉ: ์„ค๋ช…" or "ํ•ญ๋ชฉ โ†’ ์„ค๋ช…" - NO # headers โ€” use **bold text** for section titles @@ -532,6 +585,48 @@ def format_for_telegram(text: str) -> str: +# ============================================================================= +# Cron helper (Unix-standard day-of-week semantics) +# ============================================================================= + +# APScheduler 3.x CronTrigger.from_crontab() treats numeric day-of-week +# with APScheduler's internal index (0=Mon, 6=Sun) rather than Unix cron +# (0/7=Sun, 1=Mon, ..., 6=Sat). Result: "1-5" means Tue-Sat, not Mon-Fri. +# Observed on 2026-04-18 (Sat) when weekday-only crons fired. +_DOW_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat") + + +def _unix_dow_to_name(tok: str) -> str: + return _DOW_NAMES[int(tok) % 7] + + +def _translate_dow_part(part: str) -> str: + part = part.strip() + if not part or part == "*" or any(c.isalpha() for c in part): + return part + if "/" in part: + base, step = part.split("/", 1) + return f"{_translate_dow_part(base)}/{step}" + if "-" in part: + a, b = part.split("-", 1) + return f"{_unix_dow_to_name(a)}-{_unix_dow_to_name(b)}" + return _unix_dow_to_name(part) + + +def build_cron_trigger(cron_expr: str, tz): + """Build APScheduler CronTrigger with Unix-standard weekday indexing.""" + from apscheduler.triggers.cron import CronTrigger + parts = cron_expr.split() + if len(parts) != 5: + raise ValueError(f"Cron expression must have 5 fields: {cron_expr!r}") + minute, hour, day, month, dow = parts + dow_translated = ",".join(_translate_dow_part(p) for p in dow.split(",")) + return CronTrigger( + minute=minute, hour=hour, day=day, month=month, + day_of_week=dow_translated, timezone=tz, + ) + + # ============================================================================= # Dashboard # ============================================================================= @@ -826,6 +921,16 @@ def __init__(self): self._load_cron_jobs() # Session recovery state file self._state_file = Path(__file__).resolve().parent / ".liteclaw_state.json" + # Cached LiteClaw-owned session UUID (for tagging transcript entries + # and scoping /recall session). Refreshed at startup and whenever + # sessions.json changes. `None` if no id allocated yet. + self._current_session_id: str | None = self._load_current_session_id() + # Phase A: jsonl-based response extraction state. + self._jsonl_offset: int = 0 + self._skip_summarizer_once: bool = False + # Daemon-restart resilience: graceful shutdown flag + pending file. + self._shutting_down: bool = False + self._summarizer_extra_prompt: str = "" def _load_config(self): """Load persisted runtime toggles from ~/.liteclaw/config.json.""" @@ -1268,14 +1373,12 @@ def _get_cron_job(self, job_id: str) -> dict | None: def _schedule_cron_jobs(self, job_queue): """Register all enabled cron jobs with the bot's JobQueue.""" - from apscheduler.triggers.cron import CronTrigger - for job in self._cron_jobs: if not job.get("enabled", True): continue try: tz = job.get("tz", "Asia/Seoul") - trigger = CronTrigger.from_crontab(job["cron_expr"], timezone=tz) + trigger = build_cron_trigger(job["cron_expr"], tz) job_queue.run_custom( callback=self._run_cron_job, job_kwargs={ @@ -1289,12 +1392,136 @@ def _schedule_cron_jobs(self, job_queue): except Exception as e: log.warning(f"Failed to schedule cron job '{job['id']}': {e}") + async def _run_subprocess_cron(self, job: dict, bot): + """Run a cron job that's a plain shell command โ€” no Claude Code. + + Motivation: macOS LaunchAgents with StartCalendarInterval use the + `com.apple.UserEventAgent-Aqua` monitor, which silently skips jobs + when the GUI session isn't active (display lock, fast-user-switch, + etc.). LiteClaw's APScheduler runs inside the always-on daemon and + isn't subject to that gate โ€” so time-critical non-LLM jobs are + better off here. + + Job schema for subprocess type (in `.cron_jobs.json`): + { + "id": "...", + "enabled": true, + "cron_expr": "0 19 * * *", + "tz": "Asia/Seoul", + "type": "subprocess", + "command": "/path/to/venv/bin/python3 -u scripts/foo.py", + "project": "~/projects/foo", # cwd + "timeout": 1200, # seconds + "notify_telegram": true, # send summary on Telegram + "notify_tail_lines": 20 # how many log tail lines + } + """ + job_id = job["id"] + if job_id in self._cron_running: + log.info(f"Subprocess cron '{job_id}' already running, skipping") + return + self._cron_running.add(job_id) + tz = ZoneInfo(job.get("tz", "Asia/Seoul")) + started = datetime.now(tz) + log.info(f"Subprocess cron '{job_id}' starting at {started.strftime('%H:%M:%S')}") + + cmd = job.get("command", "").strip() + cwd = Path(os.path.expanduser(job.get("project", "~"))) + timeout = int(job.get("timeout", 1200)) + notify = job.get("notify_telegram", True) + tail_n = int(job.get("notify_tail_lines", 20)) + + try: + if not cmd: + raise ValueError("subprocess job has empty `command`") + proc = await asyncio.create_subprocess_shell( + cmd, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + stdout, _ = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise TimeoutError(f"subprocess exceeded {timeout}s") + rc = proc.returncode or 0 + output = (stdout or b"").decode("utf-8", errors="replace").rstrip() + elapsed = (datetime.now(tz) - started).total_seconds() + + # Log a short tail locally; don't spam the daemon log with full output + tail = "\n".join(output.splitlines()[-tail_n:]) + log.info( + f"Subprocess cron '{job_id}' rc={rc} in {elapsed:.1f}s " + f"(tail {len(tail)} chars)" + ) + if rc != 0: + raise RuntimeError(f"rc={rc}\n{tail[-2000:]}") + + # Persist job state + job["last_run"] = started.isoformat() + job["last_status"] = "ok" + self._save_cron_jobs() + + if notify: + icon = "โœ…" + summary = ( + f"{icon} cron `{job_id}` ยท {elapsed:.0f}s ยท rc=0\n" + f"```\n{tail[-1500:]}\n```" + ) + try: + await bot.send_message( + chat_id=CHAT_ID, text=summary, parse_mode="Markdown", + ) + except Exception: + # Markdown sometimes chokes on script output โ€” fall back plain. + try: + await bot.send_message(chat_id=CHAT_ID, text=summary) + except Exception as e: + log.warning(f"Subprocess cron '{job_id}' notify failed: {e}") + + except Exception as e: + log.error(f"Subprocess cron '{job_id}' failed: {e}") + job["last_run"] = datetime.now(tz).isoformat() + job["last_status"] = f"error: {e}" + self._save_cron_jobs() + try: + self._log_cron_error(job_id, job, e, session_name="(subprocess)") + except Exception: + pass + try: + await bot.send_message( + chat_id=CHAT_ID, + text=f"โŒ cron `{job_id}` failed: {e}", + parse_mode="Markdown", + ) + except Exception: + pass + finally: + self._cron_running.discard(job_id) + async def _run_cron_job(self, context): - """Execute a single cron job. Called by APScheduler.""" + """Execute a single cron job. Called by APScheduler. + + Two job types: + - (default) `type: "claude"` โ€” spawns a tmux session, runs Claude + Code, injects `job["message"]`, polls for response, summarizes + and delivers to Telegram. This is the historical LLM path. + - `type: "subprocess"` โ€” runs `job["command"]` as a plain shell + command under `job["project"]` cwd. Captures combined stdout + +stderr, sends a compact summary to Telegram. No Claude, no + tmux โ€” useful for jobs that used to live in a LaunchAgent but + suffered from macOS Aqua-session gating. + """ job = context.job.data job_id = job["id"] - session_name = f"cron-{job_id}" bot = context.bot + if job.get("type", "claude") == "subprocess": + return await self._run_subprocess_cron(job, bot) + session_name = f"cron-{job_id}" # Overlap prevention if job_id in self._cron_running: @@ -1319,26 +1546,60 @@ async def _run_cron_job(self, context): f"cd {project} && claude --dangerously-skip-permissions", "Enter"], check=True, ) - # Wait for Claude Code prompt - for _ in range(30): + # Wait for Claude Code prompt, auto-accepting the trust dialog + # that Claude shows on first launch in a new cwd. Without this, + # crons that run in unattended project dirs hang on "Do you + # trust the files in this folder?" and time out at 120s. + trust_accepted = False + for _ in range(45): await asyncio.sleep(1) try: - content = capture_pane(session_name, lines=10) - if has_prompt(content): - break + content = capture_pane(session_name, lines=20) except RuntimeError: - pass + continue + if not trust_accepted and re.search( + r"Yes, I trust|Do you trust the files|Trust the files in this folder", + content, + ): + log.info(f"Cron '{job_id}': trust prompt detected, sending Enter") + subprocess.run(["tmux", "send-keys", "-t", session_name, "Enter"], check=False) + trust_accepted = True + await asyncio.sleep(2) + continue + if has_prompt(content): + break else: - raise TimeoutError("Claude Code prompt not detected after 30s") - - # Wait if session is busy - for _ in range(60): # max 60s wait + raise TimeoutError("Claude Code prompt not detected after 45s") + + # Wait for the cron's claude session to settle before sending. + # Prefer is_idle_prompt (strict: prompt + no spinner) but fall back + # to has_prompt + stable pane content when the banner / proxy load + # keeps a faint spinner lingering. Without the fallback cron jobs + # that launch Claude Code fresh on a busy host fail with + # "Session busy for 120s" even though the pane is obviously ready. + idle = False + prev_pane = "" + stable_prompt = 0 # consecutive polls where a prompt is visible & stable + for i in range(150): # 300s total pane = capture_pane(session_name, lines=15) if is_idle_prompt(pane): + idle = True + break + if has_prompt(pane) and pane == prev_pane: + stable_prompt += 1 + else: + stable_prompt = 0 + prev_pane = pane + # 10 consecutive matching polls ร— 2s = 20s of prompt-visible + # stability is a strong signal the pane is ready even if some + # chrome glyph is still being interpreted as activity. + if stable_prompt >= 10: + log.info(f"Cron '{job_id}': proceeding on stable-prompt fallback after {(i + 1) * 2}s") + idle = True break await asyncio.sleep(2) - else: - raise TimeoutError("Session busy for 120s, giving up") + if not idle: + raise TimeoutError("Session busy for 300s, giving up") # Send the message message = job["message"] @@ -1408,16 +1669,71 @@ async def _run_cron_job(self, context): job["last_run"] = datetime.now(ZoneInfo(job.get("tz", "Asia/Seoul"))).isoformat() job["last_status"] = f"error: {e}" self._save_cron_jobs() + # Persist a forensic record for later review. Captures the tmux pane + # so we can tell whether the failure was a trust prompt, auth loop, + # infinite spinner, etc. without needing live reproduction. + try: + self._log_cron_error(job_id, job, e, session_name) + except Exception as log_exc: + log.warning(f"Could not persist cron error capture: {log_exc}") try: await bot.send_message( chat_id=CHAT_ID, - text=f"โŒ Cron '{job_id}' failed: {e}", + text=f"โŒ Cron '{job_id}' failed: {e}\n(captured โ†’ ~/.liteclaw/cron-error-capture.md)", ) except Exception: pass finally: self._cron_running.discard(job_id) + def _log_cron_error(self, job_id: str, job: dict, exc: Exception, session_name: str): + """Append a forensic markdown entry for a failed cron job.""" + path = LITECLAW_DIR / "cron-error-capture.md" + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return + ts = datetime.now(ZoneInfo(job.get("tz", "Asia/Seoul"))).isoformat(timespec="seconds") + # Pane snapshot (last 60 lines). Best-effort โ€” the session may have + # been killed already or never created. + pane = "(no pane captured)" + try: + r = subprocess.run( + ["tmux", "capture-pane", "-t", session_name, "-p", "-S", "-60"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode == 0: + pane = r.stdout.rstrip() or "(pane empty)" + except Exception: + pass + header_exists = path.exists() and path.stat().st_size > 0 + parts = [] + if not header_exists: + parts.append("# LiteClaw cron error capture\n") + parts.append("Each entry is a failed cron execution with the pane snapshot at the\n") + parts.append("moment of failure. Append-only โ€” newest at the bottom. Review later,\n") + parts.append("fix the root cause per entry, and optionally delete the entry.\n\n") + parts.append(f"## {ts} โ€” `{job_id}`\n\n") + parts.append(f"- **error**: `{exc}`\n") + parts.append(f"- **cron**: `{job.get('cron_expr', '?')}` ({job.get('tz', '?')})\n") + parts.append(f"- **project**: `{job.get('project', '?')}`\n") + parts.append(f"- **timeout**: `{job.get('timeout', '?')}s`\n") + parts.append(f"- **tmux session**: `{session_name}`\n\n") + # Truncate pane to keep the file readable โ€” 4 KB is plenty to see the + # last few dozen lines of Claude Code output around the failure. + if len(pane) > 4096: + pane = "โ€ฆ(truncated)โ€ฆ\n" + pane[-4096:] + parts.append("**pane snapshot**:\n\n") + parts.append("```\n") + parts.append(pane + ("\n" if not pane.endswith("\n") else "")) + parts.append("```\n\n---\n\n") + try: + with open(path, "a", encoding="utf-8") as f: + f.writelines(parts) + log.info(f"Cron error captured โ†’ {path}") + except OSError as e: + log.warning(f"Cron error capture write failed: {e}") + def _agent_session_alive(self, session: str) -> bool: """Check if a tmux session is still running.""" r = subprocess.run( @@ -1596,21 +1912,83 @@ def _record_offset(self): else: self._log_offset = 0 + def _load_current_session_id(self) -> str | None: + """Read the active LiteClaw session UUID from ~/.liteclaw/sessions.json. + + Returns None if the file / key is missing. Safe to call at any time โ€” + used both at startup and as a lazy refresh when _log_conversation + notices its cache is empty. + """ + try: + if LITECLAW_SESSIONS.exists(): + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) + sid = data.get("liteclaw_session_id") + if isinstance(sid, str) and sid: + return sid + except Exception: + pass + return None + + def _current_session_alias(self) -> int | None: + """Return the append-only integer index of the current session in + sessions.json.history[]. Small enough to stamp on every transcript + entry โ€” a full UUID adds ~55 bytes per line; an int adds ~10. + + Returns None if no session id is allocated yet. history[] is + append-only in _detect_resume_state, so indices are stable across + restarts and resolve back to the UUID via sessions.json. + """ + sid = self._current_session_id or self._load_current_session_id() + if not sid: + return None + try: + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) if LITECLAW_SESSIONS.exists() else {} + history = data.get("history") or [] + for idx, h in enumerate(history): + if h.get("id") == sid: + return idx + except Exception: + pass + return None + def _log_conversation(self, user_text: str, response: str, summarized: bool = False, meta: dict = None): - """Append a conversation turn to JSONL history file.""" + """Append a conversation turn to JSONL history file. + + Writes both the legacy single-file ~/.liteclaw-history.jsonl (for + back-compat with /recall) and the OpenClaw-style per-day transcript + under ~/.liteclaw/transcripts/YYYY-MM-DD.jsonl. Each entry is tagged + with the LiteClaw-owned session UUID so we can scope /recall session. + """ + now = datetime.now() + # Refresh cached session id once if it's still unknown โ€” start.sh may + # have allocated one after the daemon booted. + if not self._current_session_id: + self._current_session_id = self._load_current_session_id() + # Compact alias: integer index into sessions.json.history[] (tiny vs + # the ~55-byte UUID that would otherwise repeat on every turn). + sid_alias = self._current_session_alias() entry = { - "ts": datetime.now().isoformat(), + "ts": now.isoformat(), + "sid": sid_alias, "user": user_text[:500], # cap to avoid bloat "response": response[:2000], # keep summarized version (compact) "summarized": summarized, } if meta: entry["meta"] = meta + line = _json.dumps(entry, ensure_ascii=False) + "\n" try: with open(HISTORY_FILE, "a", encoding="utf-8") as f: - f.write(_json.dumps(entry, ensure_ascii=False) + "\n") + f.write(line) except OSError as e: log.warning(f"Failed to write history: {e}") + try: + LITECLAW_TRANSCRIPTS.mkdir(parents=True, exist_ok=True) + daily = LITECLAW_TRANSCRIPTS / f"{now.strftime('%Y-%m-%d')}.jsonl" + with open(daily, "a", encoding="utf-8") as f: + f.write(line) + except OSError as e: + log.warning(f"Failed to write daily transcript: {e}") def _log_event(self, event_type: str, detail: str = ""): """Append behavioral event to ~/.liteclaw-events.jsonl""" @@ -1621,6 +1999,653 @@ def _log_event(self, event_type: str, detail: str = ""): except Exception: pass + def _migrate_legacy_history(self) -> int: + """One-shot: split legacy single-file history into per-day transcripts. + + Idempotent โ€” if a daily file already exists with content, that day is + skipped. Returns the number of entries migrated. + """ + if not HISTORY_FILE.exists(): + return 0 + try: + LITECLAW_TRANSCRIPTS.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.warning(f"Migration: cannot create {LITECLAW_TRANSCRIPTS}: {e}") + return 0 + # Group entries by date and only append to days not yet populated. + existing_days = {p.stem for p in LITECLAW_TRANSCRIPTS.glob("*.jsonl") if p.stat().st_size > 0} + by_day: dict[str, list[str]] = {} + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + if not line: + continue + try: + entry = _json.loads(line) + ts = entry.get("ts", "") + day = ts[:10] if len(ts) >= 10 else "unknown" + except Exception: + day = "unknown" + if day in existing_days: + continue + by_day.setdefault(day, []).append(line + "\n") + except OSError as e: + log.warning(f"Migration: cannot read {HISTORY_FILE}: {e}") + return 0 + moved = 0 + for day, lines in by_day.items(): + try: + with open(LITECLAW_TRANSCRIPTS / f"{day}.jsonl", "a", encoding="utf-8") as f: + f.writelines(lines) + moved += len(lines) + except OSError as e: + log.warning(f"Migration: cannot write {day}.jsonl: {e}") + if moved: + log.info(f"Migrated {moved} legacy history entries into {LITECLAW_TRANSCRIPTS}") + return moved + + def _compact_day(self, day: str) -> dict: + """Summarize ~/.liteclaw/transcripts/{day}.jsonl into memory/{day}.md. + + Synchronous, best-effort. Skips when: + - the transcript doesn't exist or has <5 turns + - the memory file already exists (idempotent) + - the summarizer endpoint is unreachable + Returns {day, status: 'created'|'skipped'|'failed', reason, bytes}. + """ + result = {"day": day, "status": "skipped", "reason": "", "bytes": 0} + src = LITECLAW_TRANSCRIPTS / f"{day}.jsonl" + dst = LITECLAW_MEMORY / f"{day}.md" + if not src.exists(): + result["reason"] = "no transcript" + return result + if dst.exists() and dst.stat().st_size > 0: + result["reason"] = "already compacted" + return result + try: + entries = [] + with open(src, "r", encoding="utf-8") as f: + for line in f: + try: + entries.append(_json.loads(line)) + except Exception: + pass + except OSError as e: + result["reason"] = f"read failed: {e}" + return result + if len(entries) < 5: + result["reason"] = f"only {len(entries)} turns" + return result + # Build a compact prompt โ€” cap to keep token usage bounded. + bullets = [] + for e in entries[-200:]: + ts = (e.get("ts", "") or "")[:19] + u = (e.get("user") or "").replace("\n", " ")[:300] + a = (e.get("response") or "").replace("\n", " ")[:500] + bullets.append(f"- [{ts}] U: {u}\n A: {a}") + body = "\n".join(bullets) + sys_msg = ( + "You are a strategic memory compactor for an autonomous coding agent. " + "Given a day of conversation turns (user msg + agent reply summaries), " + "produce a Markdown digest with these sections:\n" + "1) Goals & decisions made today\n" + "2) Open threads / unfinished work\n" + "3) Notable bugs, incidents, or surprises\n" + "4) Useful facts the agent should remember tomorrow\n" + "Be terse. Use bullets. Korean+English mix is fine. No fluff." + ) + user_msg = f"Date: {day}\n\nTurns:\n{body}" + try: + with httpx.Client(timeout=45) as client: + r = client.post( + f"{SUMMARIZER_URL}/chat/completions", + json={ + "model": SUMMARIZER_MODEL, + "messages": [ + {"role": "system", "content": sys_msg}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.3, + }, + ) + if r.status_code != 200: + result["reason"] = f"http {r.status_code}" + return result + content = r.json()["choices"][0]["message"]["content"].strip() + except Exception as e: + result["reason"] = f"api: {e}" + return result + markdown = f"# {day} โ€” daily memory\n_compacted: {datetime.now().isoformat(timespec='seconds')}_\n\n{content}\n" + try: + LITECLAW_MEMORY.mkdir(parents=True, exist_ok=True) + dst.write_text(markdown, encoding="utf-8") + result["status"] = "created" + result["bytes"] = len(markdown.encode("utf-8")) + except OSError as e: + result["reason"] = f"write failed: {e}" + return result + + def _build_primer(self) -> dict: + """Build ~/.liteclaw/primer.md from strategic.md + recent N turns. + + Returns a dict {primer_path, size_bytes, recent_turns, has_strategic} + usable by _send_boot_ready. Never raises โ€” failures degrade silently. + """ + info = {"path": str(LITECLAW_PRIMER), "size": 0, "recent": 0, "strategic": False} + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + LITECLAW_MEMORY.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.warning(f"Primer: cannot create dirs: {e}") + return info + # Recent: last N turns from the legacy history (cheapest source covering + # all days). Once daily compaction is wired up, we can switch this to + # tail of today's transcripts/.jsonl. + recent_lines: list[str] = [] + if HISTORY_FILE.exists(): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + tail = f.readlines()[-PRIMER_RECENT_TURNS:] + for raw in tail: + try: + e = _json.loads(raw) + except Exception: + continue + ts = e.get("ts", "")[:19] + user = (e.get("user") or "").strip().replace("\n", " ") + resp = (e.get("response") or "").strip().replace("\n", " ") + recent_lines.append(f"- [{ts}] U: {user[:160]}\n A: {resp[:240]}") + info["recent"] = len(recent_lines) + except OSError as e: + log.warning(f"Primer: cannot read history: {e}") + # Strategic: optional rolling summary, with fallback to 3 most recent + # daily memory markdowns when no curated strategic.md exists yet. + strategic_text = "" + if LITECLAW_STRATEGIC.exists(): + try: + strategic_text = LITECLAW_STRATEGIC.read_text(encoding="utf-8").strip() + except OSError: + pass + if not strategic_text and LITECLAW_MEMORY.is_dir(): + try: + daily_mds = sorted(LITECLAW_MEMORY.glob("[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md")) + chunks = [] + for md in daily_mds[-3:]: + try: + chunks.append(md.read_text(encoding="utf-8").strip()) + except OSError: + continue + strategic_text = "\n\n---\n\n".join(chunks) + except OSError: + pass + info["strategic"] = bool(strategic_text) + parts = [ + "# LiteClaw startup primer", + f"_built: {datetime.now().isoformat(timespec='seconds')}_", + "", + ] + if strategic_text: + parts += ["## Strategic memory", strategic_text, ""] + if recent_lines: + parts += [f"## Recent {len(recent_lines)} turns", *recent_lines, ""] + if not strategic_text and not recent_lines: + parts.append("_(no prior context yet โ€” clean start)_") + body = "\n".join(parts) + "\n" + try: + LITECLAW_PRIMER.write_text(body, encoding="utf-8") + info["size"] = len(body.encode("utf-8")) + except OSError as e: + log.warning(f"Primer: cannot write {LITECLAW_PRIMER}: {e}") + return info + + # ---- Claude Code session JSONL reading -------------------------------- + # These helpers lift responses out of Claude's own session log at + # ~/.claude/projects//.jsonl + # instead of scraping the tmux TUI pane. The jsonl is append-only and + # structured (role/content blocks), so we get clean text + a reliable + # "this turn is done" signal via stop_reason. + def _jsonl_path(self) -> Path | None: + sid = self._current_session_id or self._load_current_session_id() + if not sid: + return None + encoded = str(CLAUDE_CWD).replace("/", "-") + p = Path.home() / ".claude" / "projects" / encoded / f"{sid}.jsonl" + return p if p.exists() else None + + def _record_jsonl_offset(self) -> None: + """Stamp the current jsonl size so we can tail from here after send.""" + try: + p = self._jsonl_path() + self._jsonl_offset = p.stat().st_size if p else 0 + except Exception: + self._jsonl_offset = 0 + + def _tail_jsonl_since_offset(self) -> tuple[str, bool, int]: + """Read new jsonl content since `self._jsonl_offset`. + + Returns (concatenated_text, turn_complete, total_bytes_read_since_offset). + text = concatenation of every assistant `text` block that appeared + after the offset (across possibly many assistant messages in + a single turn โ€” e.g. tool_use โ†’ tool_result โ†’ more text). + turn_complete = True iff the LAST assistant message observed has a + stop_reason of end_turn / stop_sequence / max_tokens. A + `tool_use` stop_reason means Claude wants a tool result next โ€” + still mid-turn, so we keep waiting. + total_bytes_read_since_offset is used by the caller to detect file + growth (Claude still writing) separately from text growth, so chains + of tool_use-only messages don't trip the "stable" early-exit. + """ + p = self._jsonl_path() + if not p or not hasattr(self, "_jsonl_offset"): + return ("", False, 0) + try: + size = p.stat().st_size + if size <= self._jsonl_offset: + return ("", False, 0) + with open(p, "rb") as f: + f.seek(self._jsonl_offset) + chunk = f.read() + except Exception as e: + log.debug(f"jsonl tail read failed: {e}") + return ("", False, 0) + texts: list[str] = [] + last_stop_reason = None + for raw in chunk.splitlines(): + if not raw.strip(): + continue + try: + obj = _json.loads(raw.decode("utf-8", errors="replace")) + except Exception: + continue + msg = obj.get("message") or {} + if msg.get("role") != "assistant": + continue + for block in msg.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + t = block.get("text") or "" + if t: + texts.append(t) + sr = msg.get("stop_reason") + if sr: + last_stop_reason = sr + turn_complete = last_stop_reason in ("end_turn", "stop_sequence", "max_tokens") + return ("\n".join(texts).strip(), turn_complete, len(chunk)) + + async def _poll_response_via_jsonl(self, timeout: float = 600.0) -> str | None: + """Wait up to `timeout` seconds for a complete assistant turn in jsonl. + + The completion signal is a stop_reason of end_turn / stop_sequence / + max_tokens on the last assistant message in the new range. A chain of + tool_use-only messages does NOT count as complete โ€” we only return + early on a true stop. + + Fallback "stable" safety net: if the jsonl file itself hasn't grown + in >15 s AND we have some text, assume the writer has flushed and + return what we have. File growth tracks BYTES (not just text blocks) + so long tool_use chains with no intermediate text don't trip it. + + Side-effect: sets `self._jsonl_delivered_complete = True` when it + returns on a genuine stop_reason, so handle_message can skip the + follow-up monitor that would otherwise overwrite the delivered + message with a stale status edit. + """ + self._jsonl_delivered_complete = False + if not USE_JSONL_RESPONSE: + return None + if not self._jsonl_path(): + return None + deadline = time.monotonic() + timeout + last_text = "" + last_bytes = 0 + quiet_since: float | None = None + while time.monotonic() < deadline: + text, complete, bytes_read = self._tail_jsonl_since_offset() + if complete and text: + self._jsonl_delivered_complete = True + return text + if bytes_read > last_bytes: + last_bytes = bytes_read + quiet_since = None # file still growing โ†’ still working + elif text and quiet_since is None: + quiet_since = time.monotonic() # growth paused, start idle timer + elif text and quiet_since is not None: + # File quiet for โ‰ฅ15 s AND we have some text โ†’ assume flushed. + if time.monotonic() - quiet_since >= 15.0: + log.info(f"jsonl quiet-since safety net tripped after {time.monotonic() - quiet_since:.1f}s idle") + # Quiet-safety-net fired โ€” text is likely final but we + # never observed a stop_reason. Treat as complete for + # follow-up suppression purposes (better than a stray + # status-edit obliterating the clean text). + self._jsonl_delivered_complete = True + return text + if text and text != last_text: + last_text = text + await asyncio.sleep(1.0) + return last_text or None + + def _detect_resume_state(self) -> dict: + """Report on the LiteClaw-owned Claude Code session. + + start.sh allocates a stable UUID and stores it in + ~/.liteclaw/sessions.json under `liteclaw_session_id`, then launches + `claude --session-id `. This pin makes resume deterministic even + when the user runs other Claude Code windows in the same cwd. + + Also maintains `history[]` โ€” appends a new entry whenever the + current session UUID changes (e.g. after --fork-session), so strategic + memory rollups can segment by session boundary instead of just by day. + """ + info = { + "resumable": False, + "session_id": None, + "session_path": None, + "newest_age_min": None, + } + existing = {} + if LITECLAW_SESSIONS.exists(): + try: + existing = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) + except Exception: + existing = {} + sess_id = existing.get("liteclaw_session_id") + info["session_id"] = sess_id + # Refresh the in-memory cache used by _log_conversation. + if sess_id: + self._current_session_id = sess_id + # Maintain session lineage. Close the prior entry if the UUID changed, + # and open a new one. History entries are append-only dicts. + now_iso = datetime.now().isoformat(timespec="seconds") + history = existing.get("history") + if not isinstance(history, list): + history = [] + last_entry = history[-1] if history else None + last_id = last_entry.get("id") if last_entry else None + if sess_id and sess_id != last_id: + if last_entry and last_entry.get("ended_at") is None: + last_entry["ended_at"] = now_iso + history.append({ + "id": sess_id, + "started_at": now_iso, + "ended_at": None, + "cwd": str(CLAUDE_CWD), + }) + existing["history"] = history + encoded = str(CLAUDE_CWD).replace("/", "-") + proj_dir = Path.home() / ".claude" / "projects" / encoded + if sess_id: + sess_file = proj_dir / f"{sess_id}.jsonl" + if sess_file.exists(): + age_s = time.time() - sess_file.stat().st_mtime + info["resumable"] = True + info["session_path"] = str(sess_file) + info["newest_age_min"] = round(age_s / 60, 1) + # Persist a refreshed snapshot, preserving start.sh's keys. + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + payload = dict(existing) + payload.update({ + "cwd": str(CLAUDE_CWD), + "encoded": encoded, + "resumable": info["resumable"], + "session_path": info["session_path"], + "newest_age_min": info["newest_age_min"], + "checked_at": datetime.now().isoformat(timespec="seconds"), + }) + LITECLAW_SESSIONS.write_text(_json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError: + pass + return info + + # ---- Daemon-restart resilience ----------------------------------------- + # When the daemon is restarted (intentional or crash) while a user reply + # is in-flight, the response would otherwise be silently lost โ€” the + # placeholder Telegram message keeps showing "โณ ์ž‘์—… ์ค‘โ€ฆ" forever. To + # avoid that, every time we send a placeholder we record the (user_msg_id, + # placeholder_msg_id, jsonl_offset, sent_at) tuple to a persistent file. + # On boot, we read that file and either deliver the now-completed answer + # from the session jsonl, resume polling, or mark the placeholder as + # "์žฌ๊ธฐ๋™์œผ๋กœ ์œ ์‹ค" if it's stale. + def _read_pending(self) -> dict: + try: + return _json.loads(LITECLAW_PENDING.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"version": 1, "pending": []} + except Exception as e: + log.warning(f"pending file unreadable, resetting: {e}") + return {"version": 1, "pending": []} + + def _write_pending(self, data: dict) -> None: + try: + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + LITECLAW_PENDING.write_text(_json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + except OSError as e: + log.warning(f"pending file write failed: {e}") + + def _record_pending(self, user_msg_id: int, placeholder_msg_id: int | None, + chat_id: int, user_text: str) -> None: + """Mark a user message as awaiting reply. Called right after the + placeholder ('โณ ์ž‘์—… ์ค‘โ€ฆ') is sent, before _poll_response begins.""" + data = self._read_pending() + # Drop any prior pending entry for this same user message (re-record on retry) + data["pending"] = [e for e in data.get("pending", []) if e.get("user_msg_id") != user_msg_id] + data["pending"].append({ + "user_msg_id": user_msg_id, + "placeholder_msg_id": placeholder_msg_id, + "chat_id": chat_id, + "user_text": (user_text or "")[:500], + "jsonl_offset": getattr(self, "_jsonl_offset", 0), + "jsonl_session_id": self._current_session_id, + "sent_at": datetime.now().isoformat(timespec="seconds"), + "target": self.target, + }) + # Cap at 50 entries to keep file small in case of leak + data["pending"] = data["pending"][-50:] + self._write_pending(data) + + def _clear_pending(self, user_msg_id: int) -> None: + """Drop the pending entry once we've successfully delivered the reply.""" + try: + data = self._read_pending() + before = len(data.get("pending", [])) + data["pending"] = [e for e in data.get("pending", []) if e.get("user_msg_id") != user_msg_id] + if len(data["pending"]) != before: + self._write_pending(data) + except Exception as e: + log.warning(f"_clear_pending failed: {e}") + + async def _resume_pending_on_boot(self, app) -> None: + """Run once on daemon startup. For each pending entry: + - if jsonl already has a complete turn โ†’ edit placeholder, drop entry + - else if entry is stale โ†’ mark placeholder as lost, drop entry + - else โ†’ spawn a background task that keeps polling jsonl + Called from Application.post_init. + """ + data = self._read_pending() + entries = data.get("pending", []) + if not entries: + return + log.info(f"Resume-on-boot: {len(entries)} pending entr{'y' if len(entries)==1 else 'ies'}") + bot = app.bot + survivors: list[dict] = [] + for entry in entries: + try: + sent_dt = datetime.fromisoformat(entry["sent_at"]) + age_s = (datetime.now() - sent_dt).total_seconds() + except Exception: + age_s = 9999.0 + user_msg_id = entry.get("user_msg_id") + ph_id = entry.get("placeholder_msg_id") + chat_id = entry.get("chat_id") + user_text_short = (entry.get("user_text") or "")[:60] + + # Stale โ†’ mark lost + if age_s > PENDING_MAX_AGE_SEC: + log.warning(f"Pending stale (age {age_s:.0f}s, msg={user_text_short!r}) โ€” marking lost") + if ph_id and chat_id: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=ph_id, + text="โŒ ๋ฐ๋ชฌ ์žฌ๊ธฐ๋™์œผ๋กœ ๋‹ต๋ณ€์ด ์œ ์‹ค๋์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋ณด๋‚ด์ฃผ์„ธ์š”.", + ) + except Exception as e: + log.warning(f"Stale-edit failed: {e}") + continue + + # Try jsonl recovery now + self._jsonl_offset = entry.get("jsonl_offset", 0) + try: + text, complete, _ = self._tail_jsonl_since_offset() + except Exception as e: + log.warning(f"Resume tail failed: {e}") + text, complete = ("", False) + + if complete and text: + log.info(f"Resume-deliver: {len(text)} chars (msg={user_text_short!r})") + try: + chunks = split_message(text) + if ph_id and chat_id: + await bot.edit_message_text( + chat_id=chat_id, message_id=ph_id, text=chunks[0], + ) + for ch in chunks[1:]: + await bot.send_message(chat_id=chat_id or CHAT_ID, text=ch) + except Exception as e: + log.warning(f"Resume-deliver edit failed: {e}") + survivors.append(entry) + continue + + # Not complete yet โ€” keep entry, schedule background polling + log.info(f"Resume-poll (background, age {age_s:.0f}s): {user_text_short!r}") + survivors.append(entry) + asyncio.create_task(self._resume_polling_task(entry, bot)) + + data["pending"] = survivors + self._write_pending(data) + + async def _resume_polling_task(self, entry: dict, bot) -> None: + """Background task that finishes a previously-in-flight reply by + waiting for the session jsonl turn to complete, then editing the + placeholder. Removes the pending entry on success or on terminal + failure (no infinite retry).""" + self._jsonl_offset = entry.get("jsonl_offset", 0) + try: + text = await self._poll_response_via_jsonl(timeout=600.0) + except Exception as e: + log.warning(f"Background resume poll failed: {e}") + text = None + if not text: + log.warning(f"Background resume gave up empty: {entry.get('user_text','')[:60]!r}") + self._clear_pending(entry.get("user_msg_id")) + return + chat_id = entry.get("chat_id") + ph_id = entry.get("placeholder_msg_id") + try: + chunks = split_message(text) + if ph_id and chat_id: + await bot.edit_message_text(chat_id=chat_id, message_id=ph_id, text=chunks[0]) + for ch in chunks[1:]: + await bot.send_message(chat_id=chat_id or CHAT_ID, text=ch) + log.info(f"Background-resumed delivery {len(text)} chars") + except Exception as e: + log.warning(f"Background resume edit failed: {e}") + finally: + self._clear_pending(entry.get("user_msg_id")) + + def _send_boot_ready(self, extras: dict = None): + """Send a one-shot 'ready' Telegram message after bot init completes. + + Synchronous (uses httpx.Client) because this fires before app.run_polling + takes over the event loop. Failures are logged but never block startup. + Set BOOT_NOTIFY=0 in .env to disable. + """ + if not BOOT_NOTIFY: + log.info("Boot notify: disabled via BOOT_NOTIFY") + # Still intentionally fall through below โ€” we want the bookkeeping + # (migrate, compact, primer build, resume state + history[]) to run + # even when the ping is disabled. We'll bail at the actual send. + # Compute rate-limit window (applied at send-time, not as an early + # return โ€” otherwise sessions.json/primer bookkeeping gets skipped). + suppress_ping = not BOOT_NOTIFY + try: + last_boot_file = LITECLAW_DIR / ".last_boot_at" + now_ts = time.time() + if last_boot_file.exists(): + try: + prev = float(last_boot_file.read_text().strip()) + except Exception: + prev = 0.0 + if now_ts - prev < 300: # 5 minutes + age = int(now_ts - prev) + log.info(f"Boot notify: suppressed (last ping {age}s ago)") + suppress_ping = True + LITECLAW_DIR.mkdir(parents=True, exist_ok=True) + last_boot_file.write_text(f"{now_ts}\n") + except Exception as e: + log.warning(f"Boot notify rate-limit check failed (continuing): {e}") + try: + import socket + host = socket.gethostname() + except Exception: + host = "?" + try: + history_turns = sum(1 for _ in open(HISTORY_FILE, "r", encoding="utf-8")) if HISTORY_FILE.exists() else 0 + except OSError: + history_turns = 0 + # Loop 4 enrichments: migrate legacy, compact yesterday, refresh primer, + # probe resume state. All best-effort โ€” failures must not block startup. + try: + self._migrate_legacy_history() + except Exception as e: + log.warning(f"Migration during boot notify failed: {e}") + try: + from datetime import timedelta + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + r = self._compact_day(yesterday) + log.info(f"Compact {yesterday}: {r['status']} ({r.get('reason') or r.get('bytes')})") + except Exception as e: + log.warning(f"Compact day failed: {e}") + primer = self._build_primer() + resume = self._detect_resume_state() + lines = [ + "๐Ÿš€ LiteClaw ready", + f"Host: {host}", + f"Target: {self.target}", + f"History: {history_turns} turns", + ] + sid = resume.get("session_id") + sid_short = sid[:8] if sid else "?" + if resume.get("resumable"): + age = resume.get("newest_age_min") + age_s = f", last touch {age}m ago" if age is not None else "" + lines.append(f"Resume: {sid_short} ({CLAUDE_CWD}{age_s})") + elif sid: + lines.append(f"Resume: {sid_short} (new session โ€” first run)") + else: + lines.append("Resume: none (no session id allocated)") + primer_kb = round(primer.get("size", 0) / 1024, 1) + strat = "+strategic" if primer.get("strategic") else "" + lines.append(f"Primer: {primer.get('recent', 0)} recent turns{strat} ({primer_kb} KB)") + if extras: + for k, v in extras.items(): + lines.append(f"{k}: {v}") + text = "\n".join(lines) + if suppress_ping: + return + try: + with httpx.Client(timeout=10) as client: + r = client.post( + f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", + json={"chat_id": CHAT_ID, "text": text, "disable_web_page_preview": True}, + ) + if r.status_code != 200: + log.warning(f"Boot notify failed: HTTP {r.status_code} {r.text[:200]}") + else: + log.info(f"Boot notify sent ({len(text)} chars)") + except Exception as e: + log.warning(f"Boot notify exception: {e}") + def _read_new_output(self) -> str: """Read new output from pipe log since last recorded offset.""" if not self._log_path or not os.path.exists(self._log_path): @@ -1732,6 +2757,45 @@ async def cmd_raw(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): mode = "ON (raw output)" if self.raw_mode else "OFF (summarized)" await update.message.reply_text(f"Raw mode: {mode}") + async def cmd_tell_summarizer(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): + """Runtime control over the summarizer system prompt. + + Usage: + /tell-summarizer โ€” append instruction (persists in-memory) + /tell-summarizer show โ€” show the current extra instructions + /tell-summarizer clear โ€” clear all extra instructions + """ + if not self._auth(update): + return + args = ctx.args or [] + joined = " ".join(args).strip() + current = getattr(self, "_summarizer_extra_prompt", "").strip() + if not args or joined.lower() == "show": + body = current or "(empty โ€” no extra instructions active)" + await update.message.reply_text( + f"๐Ÿ“ *Summarizer runtime instructions*\n\n```\n{body}\n```\n\n" + "Usage:\n" + "`/tell-summarizer ` โ€” append\n" + "`/tell-summarizer clear` โ€” wipe\n" + "`/tell-summarizer show` โ€” this view", + parse_mode="Markdown", + ) + return + if joined.lower() == "clear": + self._summarizer_extra_prompt = "" + self._log_event("summarizer_tell", "cleared") + await update.message.reply_text("๐Ÿ“ Summarizer extra instructions cleared.") + return + # Append the new instruction on its own line (multiple can accumulate) + new = (current + "\n" + joined).strip() if current else joined + self._summarizer_extra_prompt = new + self._log_event("summarizer_tell", f"appended: {joined[:200]}") + await update.message.reply_text( + f"๐Ÿ“ Summarizer now also honors:\n```\n{joined}\n```\n" + f"(Total extra length: {len(new)} chars. Use `/tell-summarizer clear` to reset.)", + parse_mode="Markdown", + ) + async def cmd_mirror(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Toggle CLI mirror โ€” forwards direct terminal activity to Telegram.""" if not self._auth(update): @@ -2069,8 +3133,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Validate cron expression try: - from apscheduler.triggers.cron import CronTrigger - CronTrigger.from_crontab(cron_expr, timezone="Asia/Seoul") + build_cron_trigger(cron_expr, "Asia/Seoul") except Exception as e: await update.message.reply_text(f"Invalid cron expression: {e}") return @@ -2092,8 +3155,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Register with scheduler if running if hasattr(ctx, "job_queue") and ctx.job_queue: try: - from apscheduler.triggers.cron import CronTrigger - trigger = CronTrigger.from_crontab(cron_expr, timezone="Asia/Seoul") + trigger = build_cron_trigger(cron_expr, "Asia/Seoul") ctx.job_queue.run_custom( callback=self._run_cron_job, job_kwargs={"trigger": trigger, "id": f"cron-{job_id}", "replace_existing": True}, @@ -2151,8 +3213,7 @@ async def cmd_cron(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): if ctx.job_queue: if enabled: try: - from apscheduler.triggers.cron import CronTrigger - trigger = CronTrigger.from_crontab(job["cron_expr"], timezone=job.get("tz", "Asia/Seoul")) + trigger = build_cron_trigger(job["cron_expr"], job.get("tz", "Asia/Seoul")) ctx.job_queue.run_custom( callback=self._run_cron_job, job_kwargs={"trigger": trigger, "id": f"cron-{job_id}", "replace_existing": True}, @@ -2654,9 +3715,12 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Recall recent conversation history, optionally filtered by keyword. Usage: - /recall โ€” summarize last 20 conversations - /recall 50 โ€” summarize last 50 conversations - /recall keyword โ€” search + summarize matching conversations + /recall โ€” summarize last 20 conversations + /recall 50 โ€” summarize last 50 conversations + /recall keyword โ€” search + summarize matching conversations + /recall session โ€” restrict to the current LiteClaw session + /recall session โ€” restrict to a specific past session UUID + /recall session keyword โ€” session-scoped keyword search """ if not self._auth(update): return @@ -2665,16 +3729,33 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("No conversation history yet.") return - # Parse args: number or keyword - args = ctx.args + # Parse args: number, keyword, or session-scope. + args = list(ctx.args) if ctx.args else [] limit = HISTORY_RECALL_LIMIT keyword = None + session_filter: str | None = None # UUID or sentinel "__current__" + if args and args[0].lower() == "session": + args = args[1:] + session_filter = "__current__" + # Optional UUID (8+ hex chars with dashes) as 2nd token + if args and re.fullmatch(r"[0-9a-fA-F-]{8,}", args[0]): + session_filter = args[0] + args = args[1:] if args: if args[0].isdigit(): limit = min(int(args[0]), 200) else: keyword = " ".join(args).lower() + # Resolve __current__ to the active UUID. + if session_filter == "__current__": + session_filter = self._current_session_id or self._load_current_session_id() + if not session_filter: + await update.message.reply_text( + "ํ˜„์žฌ ์„ธ์…˜ ID๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค (start.sh ์žฌ์‹คํ–‰ ํ•„์š” ๋˜๋Š” session_id ๋ฏธ๊ธฐ๋ก ์ƒํƒœ)." + ) + return + # Read history (tail for efficiency) try: lines = HISTORY_FILE.read_text(encoding="utf-8").strip().split("\n") @@ -2691,6 +3772,28 @@ async def cmd_recall(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): except _json.JSONDecodeError: continue + if session_filter: + # Accept both legacy full-UUID entries and compact alias entries. + # Resolve the filter to a candidate alias int via sessions.json. + sid_alias_target: int | None = None + try: + data = _json.loads(LITECLAW_SESSIONS.read_text(encoding="utf-8")) if LITECLAW_SESSIONS.exists() else {} + for idx, h in enumerate(data.get("history") or []): + if h.get("id") == session_filter: + sid_alias_target = idx + break + except Exception: + pass + def _match(e): + # New schema: "sid" is int + if "sid" in e and sid_alias_target is not None and e["sid"] == sid_alias_target: + return True + # Legacy schema: "session_id" was full UUID + if e.get("session_id") == session_filter: + return True + return False + entries = [e for e in entries if _match(e)] + if keyword: entries = [ e for e in entries @@ -2947,8 +4050,9 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): parse_mode="Markdown", ) - # Record offset before sending + # Record offset before sending (both pipe-pane log and session jsonl) self._record_offset() + self._record_jsonl_offset() # Snapshot pane BEFORE sending for reliable diff extraction pre_snapshot = capture_pane(self.target, lines=SCROLLBACK_LINES) @@ -2961,11 +4065,53 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): sent_msg = None if claude_idle: - sent_msg = await update.message.reply_text("๐Ÿ“ค Sent. Waiting for response...") + sent_msg = await update.message.reply_text("โณ ์ž‘์—… ์ค‘โ€ฆ") + + # Record pending so a daemon restart can resume delivery from the + # session jsonl instead of leaving the placeholder stuck. + try: + self._record_pending( + user_msg_id=update.message.message_id, + placeholder_msg_id=sent_msg.message_id if sent_msg else None, + chat_id=update.effective_chat.id, + user_text=user_text, + ) + except Exception as e: + log.warning(f"_record_pending failed (non-fatal): {e}") # Poll for response with streaming feedback response = await self._poll_response(ctx.bot, user_text, pre_snapshot=pre_snapshot) + # Phase A: prefer the structured session jsonl โ€” this bypasses + # tmux pane scrollback truncation, ANSI/spinner chrome ("Thinking + # (Crystalizing)โ€ฆ"), and the summarizer's over-compression. Falls + # back silently to the pane-derived response if jsonl isn't + # available (missing path, race, or unparseable). + if USE_JSONL_RESPONSE: + try: + # Generous timeout โ€” tool-heavy turns can run 5+ minutes. + # The inner loop still exits the moment stop_reason is set. + jsonl_text = await self._poll_response_via_jsonl(timeout=600.0) + except Exception as e: + log.warning(f"jsonl poll raised: {e}") + jsonl_text = None + # Sanity guard: if jsonl somehow returned *much less* than the + # pane-derived response, that usually means we returned the + # preamble text only (tool_use chain still going). Prefer the + # pane-derived version in that case so the user doesn't see a + # truncated "์ดํ•ดํ–ˆ์Šต๋‹ˆ๋‹คโ€ฆ" stub. + pane_len = len(response) + jsonl_len = len(jsonl_text or "") + if jsonl_text and (pane_len == 0 or jsonl_len >= pane_len * 0.5): + log.info(f"Response via jsonl: {jsonl_len} chars (replacing pane-derived {pane_len} chars)") + response = jsonl_text + # jsonl text is already clean โ€” skip the summarizer pass. + self._skip_summarizer_once = True + elif jsonl_text: + log.warning( + f"jsonl returned {jsonl_len} chars but pane has {pane_len} โ€” keeping pane-derived" + ) + # Delete status messages before delivering final response for msg in (sent_msg, busy_msg): if msg: @@ -2987,11 +4133,27 @@ async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE): # Deliver response (with persistent retry) delivered_msg_id = await self._deliver_response(response, user_text, update, ctx, meta=_conv_meta) - # Schedule follow-up check: if Claude produces more output, edit the message - if delivered_msg_id: + # Delivery succeeded (or we tried our best) โ€” drop the pending + # entry so the next daemon boot doesn't try to re-resume it. + try: + self._clear_pending(update.message.message_id) + except Exception: + pass + + # Schedule follow-up only when pane-scrape was the source. When the + # jsonl path returned a complete (`stop_reason=end_turn`) turn, + # there is literally no more assistant output coming โ€” spinning up + # the follow-up monitor just leads it to overwrite our clean final + # message with a "โณ Working... (Ns)" status edit (issue: user's + # delivered message gets replaced by a short truncated one). + skip_followup = bool(getattr(self, "_jsonl_delivered_complete", False)) + self._jsonl_delivered_complete = False # one-shot reset + if delivered_msg_id and not skip_followup: self._followup_task = asyncio.create_task( self._followup_edit(user_text, pre_snapshot, delivered_msg_id, ctx.bot) ) + elif skip_followup: + log.info("Skipping _followup_edit (jsonl turn already complete)") except RuntimeError as e: await update.message.reply_text(f"Error: {e}") @@ -3048,7 +4210,14 @@ async def _deliver_response(self, response: str, user_text: str, update: Update, if not response.strip(): response = "(reasoning only โ€” no final answer extracted)" - if not self.raw_mode: + # Phase A: if jsonl gave us a clean response, skip the summarizer + # entirely โ€” no ANSI/spinner noise to strip, no content compression + # to suffer. One-shot flag cleared immediately. + skip_sum = getattr(self, "_skip_summarizer_once", False) + self._skip_summarizer_once = False + if skip_sum: + log.info(f"Summarizer skipped (jsonl-sourced response, {len(response)} chars)") + elif not self.raw_mode: try: await ctx.bot.send_chat_action(chat_id=CHAT_ID, action=ChatAction.TYPING) except Exception: @@ -3641,8 +4810,10 @@ async def _poll_response(self, bot, user_text: str = "", pre_snapshot: str = "") self._last_interactive_options = list(interactive.get("options", [])) await self._send_interactive_prompt(bot, interactive) - # Status update at adaptive interval โ€” show last few meaningful lines - if elapsed - last_status >= status_interval: + # Status update at adaptive interval โ€” show last few meaningful lines. + # Gated by SHOW_POLLING_STATUS: off by default because this path is + # what surfaced "Thinking (Crystalizing)โ€ฆ" TUI chrome in Telegram. + if SHOW_POLLING_STATUS and elapsed - last_status >= status_interval: last_status = elapsed status_capture = capture_pane(self.target, lines=15) preview_text = clean_output(status_capture).strip() @@ -3841,20 +5012,33 @@ async def _probe_api(self) -> bool: return False async def _recover_proxy(self) -> bool: - """Try to restart max-api-proxy Docker container.""" + """Try to restart the API proxy. macOS uses LaunchAgent; Linux uses Docker compose.""" log.warning("Attempting to restart max-api-proxy...") try: - r = subprocess.run( - ["docker", "compose", "up", "-d"], - cwd=os.environ.get("PROXY_DIR", os.path.expanduser("~/max_api_proxy")), - capture_output=True, text=True, timeout=30, - ) + if sys.platform == "darwin": + # Docker on macOS cannot access the keychain where Claude CLI stores OAuth + # tokens, so the proxy runs as a LaunchAgent instead. See MAC-OPS.md. + label = f"gui/{os.getuid()}/com.claude-max-api-proxy" + r = subprocess.run( + ["launchctl", "kickstart", "-k", label], + capture_output=True, text=True, timeout=15, + ) + settle_s = 5 + else: + r = subprocess.run( + ["docker", "compose", "up", "-d"], + cwd=os.environ.get("PROXY_DIR", os.path.expanduser("~/max_api_proxy")), + capture_output=True, text=True, timeout=30, + ) + settle_s = 3 if r.returncode == 0: - await asyncio.sleep(3) + await asyncio.sleep(settle_s) if await self._probe_api(): log.info("max-api-proxy recovered successfully") await self._notify_recovery("max-api-proxy restarted") return True + else: + log.warning(f"Proxy recovery returncode={r.returncode} stderr={r.stderr[:200]}") except Exception as e: log.warning(f"Proxy recovery failed: {e}") return False @@ -4093,11 +5277,20 @@ async def _summarize(self, user_question: str, raw_output: str) -> str: log.info("Skipping summarize: too short") return raw_output - # Tier 1: API proxy + # Tier 1: API proxy. Append any runtime extra instruction the user set + # via /tell-summarizer so they can tune behavior without code edits. + system_prompt = SUMMARIZE_PROMPT + extra = getattr(self, "_summarizer_extra_prompt", "").strip() + if extra: + system_prompt = ( + SUMMARIZE_PROMPT + + "\n\nUSER-PROVIDED RUNTIME INSTRUCTIONS (from /tell-summarizer โ€” honor these):\n" + + extra + ) tier1_payload = { "model": SUMMARIZER_MODEL, "messages": [ - {"role": "system", "content": SUMMARIZE_PROMPT}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": ( f"[REFORMAT TASK] Clean up the following terminal output for Telegram delivery.\n" f"Context โ€” the user asked: \"{user_question}\"\n\n" @@ -4180,6 +5373,12 @@ async def on_shutdown(app): async def on_init(app): # Cache bot reference for background tasks (e.g. CLI mirror) self._bot_ref = app.bot + # Resume any pending replies left behind by a previous daemon + # instance (crash or graceful restart). See _resume_pending_on_boot. + try: + await self._resume_pending_on_boot(app) + except Exception as e: + log.warning(f"Resume-on-boot failed (non-fatal): {e}") if self._cron_jobs: self._schedule_cron_jobs(app.job_queue) log.info(f"Cron scheduler initialized with {len(self._cron_jobs)} job(s)") @@ -4231,6 +5430,8 @@ async def on_init(app): app.add_handler(CommandHandler("cancel", self.cmd_cancel)) app.add_handler(CommandHandler("escape", self.cmd_escape)) app.add_handler(CommandHandler("raw", self.cmd_raw)) + app.add_handler(CommandHandler("tell_summarizer", self.cmd_tell_summarizer)) + app.add_handler(CommandHandler("tellsum", self.cmd_tell_summarizer)) # short alias app.add_handler(CommandHandler("mirror", self.cmd_mirror)) app.add_handler(CommandHandler("reasoning", self.cmd_reasoning)) app.add_handler(CommandHandler("model", self.cmd_model)) @@ -4273,7 +5474,19 @@ async def on_init(app): log.info(f"Bridge started. Target: {self.target}") log.info("Send a message to your Telegram bot to begin.") - app.run_polling(drop_pending_updates=False) + # Loop 4 will populate `extras` with resume/primer status; for now this + # ships the basic ready ping so users know startup completed. + self._send_boot_ready() + # Graceful shutdown: PTB's run_polling handles SIGINT/SIGTERM by + # default and tries to finish in-flight handlers before exiting. The + # stop_signals tuple is explicit here to make the behavior obvious. + # Hard kills (SIGKILL, crash) are covered by the pending-reply resume + # path in _resume_pending_on_boot, not by this signal handler. + import signal as _signal + app.run_polling( + drop_pending_updates=False, + stop_signals=(_signal.SIGINT, _signal.SIGTERM), + ) def _is_binary(path: Path) -> bool: diff --git a/setup.sh b/setup.sh index e583443..938462b 100755 --- a/setup.sh +++ b/setup.sh @@ -29,6 +29,54 @@ if ! command -v tmux &>/dev/null; then fi echo "[OK] tmux $(tmux -V)" +# 1b. Ensure ~/.tmux.conf has mouse + scrollback so users can wheel-scroll the +# claude/liteclaw panes (default tmux disables mouse and caps history at 2000). +TMUX_CONF="$HOME/.tmux.conf" +TMUX_ADDED=0 +ensure_tmux_line() { + local line="$1" key="$2" + if [ -f "$TMUX_CONF" ] && grep -qE "^\s*set(-option)?\s+-g\s+${key}\b" "$TMUX_CONF"; then + return + fi + printf '%s\n' "$line" >> "$TMUX_CONF" + TMUX_ADDED=1 +} +ensure_tmux_line "set -g mouse on" "mouse" +ensure_tmux_line "set -g history-limit 50000" "history-limit" +if [ "$TMUX_ADDED" -eq 1 ]; then + echo "[OK] Updated $TMUX_CONF (mouse on, history-limit 50000)" + # Apply to any running sessions so the change takes effect immediately. + if tmux list-sessions >/dev/null 2>&1; then + tmux source-file "$TMUX_CONF" >/dev/null 2>&1 || true + fi +else + echo "[OK] tmux mouse/history config already present" +fi + +# 1c. Install global `liteclaw` CLI symlink in ~/.local/bin (idempotent). +# Symlink (not copy) so subsequent repo updates take effect without re-install. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DISPATCHER="$SCRIPT_DIR/bin/liteclaw" +LINK_DIR="$HOME/.local/bin" +LINK="$LINK_DIR/liteclaw" +if [ -x "$DISPATCHER" ]; then + mkdir -p "$LINK_DIR" + if [ -L "$LINK" ] && [ "$(readlink "$LINK")" = "$DISPATCHER" ]; then + echo "[OK] liteclaw CLI already linked at $LINK" + else + ln -sfn "$DISPATCHER" "$LINK" + echo "[OK] Linked liteclaw CLI: $LINK -> $DISPATCHER" + fi + case ":$PATH:" in + *":$LINK_DIR:"*) ;; + *) echo "[!] $LINK_DIR is not in PATH โ€” add to your shell rc:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + esac +else + echo "[!] $DISPATCHER not executable โ€” skipping CLI symlink" +fi + # 2. Find Python 3.10+ (prefer newer versions, fall back through named interpreters) # On macOS, `python3` often resolves to system 3.9 even when brew python@3.12 is installed, # because brew's formula only provides versioned binaries (python3.12) without a generic diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..b5578e3 --- /dev/null +++ b/start.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# start.sh โ€” bring up Claude Code + LiteClaw together. +# Order matters: LiteClaw injects messages into the tmux 'claude' pane, +# so Claude Code must be running there first. +# +# Usage: +# bash ~/projects/liteclaw/start.sh # detached +# bash ~/projects/liteclaw/start.sh --attach # detached + attach to claude + +set -euo pipefail + +REPO="$HOME/projects/liteclaw" +LOG="/tmp/liteclaw_run.log" +# Where claude is launched. Pinned so the LiteClaw-owned session always lives +# under ~/.claude/projects//.jsonl, regardless +# of where the user launches `liteclaw start` from. +CLAUDE_CWD="${CLAUDE_CWD:-$HOME}" +LITECLAW_DIR="${LITECLAW_DIR:-$HOME/.liteclaw}" +SESSIONS_JSON="$LITECLAW_DIR/sessions.json" + +# Resolve a stable LiteClaw-owned session UUID. We pre-allocate a UUID and pass +# it via `--session-id` so `liteclaw start` always resumes the same conversation +# even when the user has other Claude Code windows running in the same cwd. +mkdir -p "$LITECLAW_DIR" +SESS="" +if [ -f "$SESSIONS_JSON" ]; then + SESS=$(python3 -c " +import json, sys +try: + print(json.load(open('$SESSIONS_JSON')).get('liteclaw_session_id', '')) +except Exception: + print('') +" 2>/dev/null || true) +fi +if [ -z "$SESS" ]; then + # Migration: if this cwd already has exactly one Claude Code session jsonl, + # adopt its UUID so existing conversations carry over. Otherwise allocate fresh. + ENC=$(echo "$CLAUDE_CWD" | sed 's|/|-|g') + PROJ_DIR="$HOME/.claude/projects/$ENC" + ADOPT="" + if [ -d "$PROJ_DIR" ]; then + # Pick the most recently modified jsonl (user's active session wins in ties). + ADOPT=$(ls -t "$PROJ_DIR"/*.jsonl 2>/dev/null | head -1 | xargs -n1 basename 2>/dev/null | sed 's/\.jsonl$//') + fi + if [ -n "$ADOPT" ]; then + SESS="$ADOPT" + note="adopted existing session" + else + SESS=$(uuidgen | tr '[:upper:]' '[:lower:]') + note="allocated new session" + fi + python3 - </dev/null; then + echo "โœ“ tmux 'claude' already running" +else + echo "โ†’ Starting Claude Code in tmux 'claude' (cwd=$CLAUDE_CWD, session=$SESS)..." + tmux new-session -d -s claude -c "$CLAUDE_CWD" "claude --session-id $SESS --dangerously-skip-permissions" + # Wait up to 20s for Claude prompt or trust-prompt + for _ in $(seq 1 20); do + pane=$(tmux capture-pane -t claude -p 2>/dev/null || true) + if echo "$pane" | grep -qE "โฏ|Yes, I trust"; then + break + fi + sleep 1 + done + if echo "$pane" | grep -q "Yes, I trust"; then + echo " โš  trust-prompt detected โ€” sending Enter to accept" + tmux send-keys -t claude Enter + sleep 2 + fi + echo " ready" +fi + +# 2) LiteClaw daemon in tmux 'liteclaw' +if pgrep -f "python.*liteclaw.py" >/dev/null; then + echo "โœ“ LiteClaw already running (pid $(pgrep -f 'python.*liteclaw.py' | head -1))" +else + echo "โ†’ Starting LiteClaw daemon..." + tmux new-session -d -s liteclaw "cd $REPO && .venv/bin/python3 liteclaw.py 2>&1 | tee -a $LOG" + sleep 4 + pid=$(pgrep -f "python.*liteclaw.py" | head -1 || true) + if [ -n "$pid" ]; then + echo " pid $pid" + else + echo " โœ— failed to start โ€” check $LOG" + exit 1 + fi +fi + +echo +echo "Sessions:" +tmux list-sessions | grep -E "^(claude|liteclaw):" +echo +echo "Logs: tail -f $LOG" +echo "Attach: tmux attach -t claude" + +# Optional: attach if requested +if [ "${1:-}" = "--attach" ]; then + exec tmux attach -t claude +fi