Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation,
- `tool_types.py` — Tool type definitions (ToolDefinition, ToolResult)
- `evolution_prompt.md` — Evolution prompt template
- `emrg/tools/` — Tool implementations (bash, read, write, edit, glob, grep, base + registry)
- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure)
- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure) + installable-skills catalog (`skill-catalog.md`, `/skills available|install|update`)
- `emrg/client/` — Client (TUI interface based on inlined python-tui)
- `daemon_manager.py` — Daemon lifecycle (start/restart-if-stale/ensure-connected) + protocol client (DaemonConnection: send_task/send_command/recv/read_stream) — shared with GUI (Phase 3)
- `app.py` — Main entry, event loop, ChatHistory widget, command autocomplete, session selector
Expand DownExpand Up@@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (599) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (634) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -221,7 +221,7 @@ vision = true
| `/delete [id]` | 删除会话——不带参数进入交互式选择器 |
| `/rewind` | 回退对话——选择历史消息点,截断后续内容 |
| `/trigger` | 触发演化任务——交互式选择器(↑↓/j/k) |
| `/skills` | 列出已加载的技能模块 |
| `/skills` | 列出已加载技能(含 skill-catalog);`/skills available`=可安装目录,`/skills install <name>`=安装,`/skills update`=刷新受管技能 |
| `/version` | 显示 EMRG 版本和实例信息 |
| `Esc` | 中断正在运行的响应 |
| `Ctrl+C` / `exit` | 退出 |
Expand DownExpand Up@@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
uv run pytest tests/ -v # 跑测试(当前 599 项)
uv run pytest tests/ -v # 跑测试(当前 634 项)
uv run python -m emrg # 启动 TUI
# CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,7 +219,7 @@ vision = true
| `/delete [id]` | Delete a session — no args for interactive picker |
| `/rewind` | Rewind conversation — pick a history point and truncate after it |
| `/trigger` | Trigger an evolution task — interactive picker (↑↓/j/k) |
| `/skills` | List loaded skill modules |
| `/skills` | List loaded skills (incl. skill-catalog); `/skills available` = installable catalog, `/skills install <name>` = install, `/skills update` = refresh managed skills |
| `/version` | Show EMRG version and instance info |
| `Esc` | Interrupt a running response mid-stream |
| `Ctrl+C` / `exit` | Quit |
Expand DownExpand Up@@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own.
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # install deps
uv run pytest tests/ -v # run tests (currently 599 items)
uv run pytest tests/ -v # run tests (currently 634 items)
uv run python -m emrg # launch TUI
# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI

Expand Down
116 changes: 107 additions & 9 deletions emrg/client/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,6 +266,7 @@ async def _run_elapsed_timer() -> None:
rewind_sel = SelectorState()
task_sel = SelectorState()
_rant_project: str | None = None # Set after project selection, used on next Enter
_skills_confirm: tuple | None = None # (skill_name, install_cmd) — next Enter answers the prompt

# Command autocomplete state (shows dropdown when user types /)
_autocomplete_active = False
Expand DownExpand Up@@ -699,6 +700,71 @@ async def _reconnect():
term.render()
continue

# Skills available result (installable-skills catalog)
if data.get("type") == "skills_available_result":
skills = data.get("skills", [])
err = data.get("error", "")
if err:
chat.add("system", f"Error: {err}")
elif not skills:
chat.add("system", "No catalog skills found. Check ~/.emrg/skills/skill-catalog.md")
else:
lines = ["**Available Skills (catalog):**", ""]
for s in skills:
mark = "✅ installed" if s.get("installed") else "not installed"
if s.get("managed"):
mark += " · managed"
lines.append(f"- **{s.get('name', '?')}** — {s.get('description', '')} ({mark})")
lines.append("")
lines.append("Install: `/skills install <name>` · Refresh: `/skills update`")
chat.add("system", "\n".join(lines))
term.render()
continue

# Skills install result
if data.get("type") == "skills_install_result":
nonlocal _skills_confirm
name = data.get("name", "")
if data.get("confirm_required"):
cmd = data.get("install_command", "")
_skills_confirm = (name, cmd)
chat.add("system",
f"⚠️ Skill `{name}` needs its CLI installed first:\n"
f"`{cmd}`\n\n"
f"Type `yes` to confirm, or anything else to cancel.")
elif data.get("error"):
chat.add("system", f"Install failed for `{name}`: {data['error']}")
elif data.get("ok"):
chat.add("system",
f"✅ Skill `{name}` installed"
+ (f" (v{data.get('version', '?')})" if data.get("version") else "")
+ ". It will appear in the next session's Available Skills.")
term.render()
continue

# Skills update result
if data.get("type") == "skills_update_result":
err = data.get("error", "")
checked = data.get("checked", 0)
updated = data.get("updated", [])
skipped = data.get("skipped", [])
errors = data.get("errors", [])
if err:
chat.add("system", f"Skill update failed: {err}")
else:
lines = [f"**Skill update check:** {checked} managed skill(s)"]
if updated:
lines.append(f"Updated: {', '.join(updated)}")
if skipped:
lines.append(f"Skipped (CLI missing): {', '.join(skipped)}")
if errors:
lines.append(f"Failed: {', '.join(errors)}")
if not updated and not skipped and not errors:
lines.append("All up to date.")
chat.add("system", "\n".join(lines))
term.render()
continue

# Resume result
if data.get("type") == "resume_result":
err = data.get("error", "")
Expand DownExpand Up@@ -952,6 +1018,7 @@ async def handle_key(data: bytes) -> bool:
nonlocal history_index, history_saved_input
nonlocal _autocomplete_active, _autocomplete_widget
nonlocal _request_start, _last_center, _elapsed_task, _pending_images
nonlocal _skills_confirm
if len(data) == 0: return True
if data == b"\x1b[200~": paste_mode = True; return True
if data == b"\x1b[201~":
Expand DownExpand Up@@ -1354,6 +1421,19 @@ async def handle_key(data: bytes) -> bool:
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

# Pending /skills install confirmation — next line is the answer
if _skills_confirm is not None:
name, cmd = _skills_confirm
_skills_confirm = None
if text.lower() in ("y", "yes"):
await conn.send_command("skills_install", name=name, confirmed=True)
chat.add("system", f"Confirmed — installing `{name}` (CLI: `{cmd}`)…")
else:
chat.add("system", "Install cancelled.")
status.update(center=server_id or "emrg")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

# Handle /memory command
if text.lower().startswith("/memory"):
parts = text.split(None, 1)
Expand DownExpand Up@@ -1435,16 +1515,34 @@ async def handle_key(data: bytes) -> bool:
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

# Handle /skills command
if text.lower() == "/skills":
skills = load_skills()
if skills:
lines = ["**Loaded Skills:**", ""]
for s in skills:
lines.append(f"- **{s.name}** ({s.source}) — {s.description}")
chat.add("system", "\n".join(lines))
# Handle /skills command (list / available / install / update)
if text.lower().startswith("/skills"):
parts = text.split(None, 1)
sub = parts[1].strip() if len(parts) > 1 else ""
sub_l = sub.lower()
if sub_l == "available":
# Installable-skills catalog (rant 2026-08-08T10:14:29)
await conn.send_command("skills_available")
status.update(center="checking available skills…")
elif sub_l.startswith("install "):
name = sub[8:].strip()
if not name:
chat.add("system", "Usage: /skills install <name>")
else:
await conn.send_command("skills_install", name=name, confirmed=False)
status.update(center=f"installing {name}…")
elif sub_l == "update":
await conn.send_command("skills_update")
status.update(center="checking skill updates…")
else:
chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/")
skills = load_skills()
if skills:
lines = ["**Loaded Skills:**", ""]
for s in skills:
lines.append(f"- **{s.name}** ({s.source}) — {s.description}")
chat.add("system", "\n".join(lines))
else:
chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

Expand Down
81 changes: 81 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ def _redact(value):
from emrg.tools.glob_tool import GlobTool
from emrg.tools.grep_tool import GrepTool
from emrg.skills.loader import load_skills
from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed
from emrg.server.scheduler import TaskScheduler

logger = logging.getLogger(__name__)
Expand DownExpand Up@@ -148,6 +149,13 @@ def __init__(self, llm_config: LlmConfig) -> None:
runtime_dir.mkdir(parents=True, exist_ok=True)
# Ensure skills directory exists for evolution-installed skills
(runtime_dir / "skills").mkdir(exist_ok=True)
# Installable-skills catalog baseline (rant 2026-08-08T10:14:29):
# the catalog is itself a skill (skill-catalog.md); on upgrades or
# user deletion the daemon re-writes it from the embedded baseline.
try:
ensure_catalog_file()
except Exception:
logger.debug("could not ensure skill catalog", exc_info=True)

host_name = platform.node()
self.identity = InstanceIdentity(
Expand DownExpand Up@@ -269,11 +277,22 @@ async def serve(self) -> None:
self._scheduler = TaskScheduler(self.identity)
self._scheduler.load_and_start()

# Background deterministic skill update check (rant 2026-08-08T10:14:29):
# runs at startup + every 24h — refreshes managed skills to their
# latest GitHub releases. Never installs a CLI silently, never touches
# host-modified skill copies.
self._skills_ttl_task = asyncio.create_task(self._skills_ttl_loop())

try:
await self._server.serve_forever()
except asyncio.CancelledError:
pass
finally:
self._skills_ttl_task.cancel()
try:
await self._skills_ttl_task
except (asyncio.CancelledError, Exception):
pass
self._scheduler.stop_all()
await self._scheduler.wait_all()
await self.llm.close()
Expand All@@ -286,6 +305,25 @@ async def serve(self) -> None:
except OSError:
pass

async def _skills_ttl_loop(self) -> None:
"""Background deterministic skill update check (startup + every 24h).

Design (rant 2026-08-08T10:14:29 §6): the check is deterministic
logic, not LLM thinking — on each tick, refresh every managed=true
skill whose latest GitHub release differs from the recorded version.
Failures are logged at debug level and never crash the daemon.
"""
from emrg.skills.installer import _UPDATE_TTL_SECONDS, run_update_check_once

while True:
result = await run_update_check_once()
if result.get("updated"):
logger.info("skills auto-updated: %s", result["updated"])
self.skills = load_skills()
elif result.get("errors"):
logger.warning("skills update errors: %s", result["errors"])
await asyncio.sleep(_UPDATE_TTL_SECONDS)

def _evolution_count(self) -> int:
"""Total completed evolution cycles across scheduler handlers + disk.

Expand DownExpand Up@@ -1165,6 +1203,49 @@ async def _process_message(
count, f" project={project}" if project else "", _redact_string(rant_message[:100]))
await self._send(ws, {"ok": True, "count": count})

elif msg_type == "skills_available":
# Installable-skills catalog (rant 2026-08-08T10:14:29): list
# catalog skills with installed/managed status.
entries = load_catalog_skills()
installed = {s.name for s in self.skills}
result = [
{
"name": e.get("name", ""),
"description": e.get("description", ""),
"installed": e.get("name", "") in installed,
"managed": skill_is_managed(e.get("name", "")),
}
for e in entries
]
await self._send(ws, {"type": "skills_available_result", "skills": result})

elif msg_type == "skills_install":
# /skills install <name> — host-confirmed CLI install, then
# self-publish skill files into ~/.emrg/skills/.
from emrg.skills.installer import install_skill

name = msg.get("name", "").strip()
confirmed = bool(msg.get("confirmed", False))
if not name:
await self._send(ws, {
"type": "skills_install_result",
"error": "skills_install requires a skill name",
})
return
result = await install_skill(name, confirmed=confirmed)
if result.get("ok"):
# Reload so the next system-prompt build includes the skill
# (design: "下次构建系统提示即含该技能", no daemon restart needed).
self.skills = load_skills()
await self._send(ws, {"type": "skills_install_result", "name": name, **result})

elif msg_type == "skills_update":
# /skills update — refresh managed skills to latest releases.
from emrg.skills.installer import update_managed_skills

result = await update_managed_skills()
await self._send(ws, {"type": "skills_update_result", **result})

elif msg_type == "list_models":
await self._handle_list_models(ws)

Expand Down
Loading
Loading