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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,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` (858) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (867) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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
49 changes: 36 additions & 13 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1554,11 +1554,24 @@ async def handle_key(data: bytes) -> bool:
if text.lower() in ("quit", "exit"): return False

# If a rant project was selected, use this message as the rant
# (rant 2026-08-17T11:51:59: routes through the agent for
# polish/confirm, then the submit_rant tool records it)
if _rant_project:
await conn.send_command("rant", message=text, project=_rant_project,
timestamp=datetime.now().isoformat())

chat.add("system", f"Rant recorded (@{_rant_project}). The evolution system will review it.")
hint = (
f"[Host wants to submit this rant — polish it, ask for "
f"confirmation if needed, then call submit_rant "
f"(project: {_rant_project})]\n{text}"
)
chat.add("user", f"/rant @{_rant_project} {text}")
chat.add("assistant", "")
msg_count += 1; _update_left_extra()
_last_center = "thinking..."
status.update(center=_last_center)
term.render()
rid = await conn.send_task(session_id=session_id, cwd=cwd,
prompt=hint)
if was_busy:
_queued_sends.append({"id": rid, "prompt": hint, "images": None})
_rant_project = None
status.update(center=server_id or "emrg")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
Expand Down Expand Up @@ -1829,6 +1842,10 @@ def _is_image_token(s, i):
return True

# Handle /rant command
# Rant 2026-08-17T11:51:59: /rant is no longer a direct write —
# it is a hint that the user wants to submit a rant. The text
# goes through the normal conversation so the agent can
# clarify / polish / confirm, then call the submit_rant tool.
if text.lower().startswith("/rant"):
parts = text.split(None, 2)
message = parts[1].strip() if len(parts) > 1 else ""
Expand All @@ -1850,16 +1867,22 @@ def _is_image_token(s, i):
status.update(center="loading projects...")
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True
payload = {
"message": message,
"timestamp": datetime.now().isoformat(),
}
if project:
payload["project"] = project
await conn.send_command("rant", **payload)

target = f" (@{project})" if project else ""
chat.add("system", f"Rant recorded{target}. The evolution system will review it.")
hint = (
f"[Host wants to submit this rant — polish it, ask for "
f"confirmation if needed, then call submit_rant "
f"(project: {project if project else 'emrg'})]\n{message}"
)
chat.add("user", f"/rant{target} {message}")
chat.add("assistant", "")
msg_count += 1; _update_left_extra()
_last_center = "thinking..."
status.update(center=_last_center)
term.render()
rid = await conn.send_task(session_id=session_id, cwd=cwd,
prompt=hint)
if was_busy:
_queued_sends.append({"id": rid, "prompt": hint, "images": None})
inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render()
return True

Expand Down
71 changes: 30 additions & 41 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ def _redact(value):
from emrg.tools.edit_tool import EditTool
from emrg.tools.glob_tool import GlobTool
from emrg.tools.grep_tool import GrepTool
from emrg.tools.submit_rant_tool import SubmitRantTool
from emrg.skills.loader import load_skills
from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed
from emrg.server.rants import append_rant
from emrg.server.scheduler import TaskScheduler

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -196,6 +198,7 @@ def __init__(self, llm_config: LlmConfig) -> None:
self.tools.register(EditTool())
self.tools.register(GlobTool())
self.tools.register(GrepTool())
self.tools.register(SubmitRantTool())
logger.info("tools registered: %s", self.tools.names)

# Load skills
Expand Down Expand Up @@ -1515,44 +1518,11 @@ async def _process_message(
# Optional project targeting (multi-project support)
project = msg.get("project", "").strip()

# Field order: timestamp → project → status → progress → completed → message
# (project right after timestamp per user feedback; message last)
# Timestamp is daemon-authoritative local time (rant 2026-08-07T13:34Z):
# clients previously supplied timestamps — GUI sent new Date().toISOString()
# (UTC, 8h behind on UTC+8 hosts), TUI sent naive local time. A tz-aware
# local ISO timestamp (+08:00) is self-describing, sorts correctly, and is
# consistent regardless of which client submitted the rant.
entry = {
"timestamp": datetime.now().astimezone().isoformat(),
"project": project,
"status": "pending",
"progress": None,
"completed": None,
}
# message last, so status fields stay visible when scanning the file
entry["message"] = rant_message

self._rants_log.parent.mkdir(parents=True, exist_ok=True)

# Read existing rants, append new, sort by timestamp, rewrite sorted
rants: list[dict] = []
if self._rants_log.exists():
with open(self._rants_log, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
rants.append(json.loads(line))
except json.JSONDecodeError:
pass
rants.append(entry)
rants.sort(key=lambda r: r.get("timestamp", ""))

with open(self._rants_log, "w", encoding="utf-8") as f:
for r in rants:
f.write(json.dumps(r, ensure_ascii=False) + "\n")

count = len(rants)
# Shared write logic (rant 2026-08-17T11:51:59): daemon ``rant``
# command and the submit_rant tool use the same append_rant, so
# the file format / sort / daemon-authoritative timestamp stay
# consistent no matter which path recorded the rant.
count = append_rant(self._rants_log, rant_message, project)

logger.info("rant recorded (%d total)%s: %s",
count, f" project={project}" if project else "", _redact_string(rant_message[:100]))
Expand Down Expand Up @@ -2418,7 +2388,13 @@ async def _run_tool_loop(
except json.JSONDecodeError:
args = {}

logger.info("tool call: %s(%s)", tc_name,
# Rant 2026-08-17T12:03:13: log the human-readable purpose
# alongside the tool name so background/reflection calls
# (memory reflection / consolidation) are understandable
# without context.
tool_obj = self.tools.get(tc_name)
purpose = tool_obj.definition().purpose if tool_obj else "unknown tool"
logger.info("tool call: %s — %s (%s)", tc_name, purpose,
json.dumps(_redact(args), ensure_ascii=False)[:200])

# Notify client (broadcast to all session subscribers)
Expand Down Expand Up @@ -3517,7 +3493,14 @@ async def _reflect():
"tool_call_id": tc_id,
"content": result_text,
})
logger.debug("memory reflection tool: %s → %s", tc_name, _redact_string(result_text[:100]))
# Rant 2026-08-17T12:03:13: include the human-readable purpose
purpose = tool.definition().purpose if tool else "unknown tool"
logger.debug(
"memory reflection: id=%s round=%d tool %s — %s → %s%s",
session.session_id, _round + 1, tc_name, purpose,
_redact_string(result_text[:100]),
"…" if len(result_text) > 100 else "",
)

except Exception:
logger.debug("memory reflection failed", exc_info=True)
Expand Down Expand Up @@ -3621,7 +3604,13 @@ async def _consolidate_session_memories(
"tool_call_id": tc_id,
"content": result_text,
})
logger.debug("consolidation tool: %s → %s", tc_name, _redact_string(result_text[:100]))
# Rant 2026-08-17T12:03:13: include the human-readable purpose
purpose = tool.definition().purpose if tool else "unknown tool"
logger.debug(
"consolidation tool: %s — %s → %s%s",
tc_name, purpose, _redact_string(result_text[:100]),
"…" if len(result_text) > 100 else "",
)
except Exception:
logger.debug("memory consolidation failed", exc_info=True)

Expand Down
16 changes: 16 additions & 0 deletions emrg/server/prompts/system.j2
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,19 @@ When modifying or consolidating memories, check the timestamps to gauge how sett
- If a body explicitly says "temporary" / "for now" / "placeholder", it's safe to replace or remove when circumstances change

Session-scope memories that have lasting value can be promoted to project scope by moving the file to `.emrg/memory/` and updating both MEMORY.md indexes.

## Rant Handling

A rant (吐槽) is feedback from the host — a complaint, bug report, feature request, or improvement suggestion about EMRG itself or any registered project. Rants are not a special mode: they appear naturally in normal conversation ("this feature is bad", "there's a bug", "it should…", "why not…").

**Recognition** — do not wait for a `/rant` prefix. Detect rant intent from ordinary messages: complaints, criticism, "should / why not", dissatisfaction with behavior or output.

**Flow**:
1. Detect rant intent → confirm with the host: "Is this feedback you'd like to submit?" (skip the question only when the intent is unmistakable).
2. If information is incomplete (target project? concrete suggestion / expected behavior?) → ask clarifying questions. The `submit_rant` tool requires a `project` — if you don't know which project the rant targets, ask the user first.
3. Polish/structure the raw speech into a clear, actionable rant description.
4. **Show the polished result and get explicit consent** → then call the `submit_rant` tool (with the confirmed `project`).
5. If the host says "don't submit / never mind" → do not call the tool.

Calling the tool IS the confirmed signal. Never call it without explicit user agreement. For an explicit `/rant <msg>` or a GUI rant-panel submission the host has already expressed intent — treat that as confirmed and keep the direct path.

65 changes: 65 additions & 0 deletions emrg/server/rants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Shared rant-write logic — single source of truth for rants.jsonl.

Extracted from the daemon's ``rant`` handler (rant 2026-08-17T11:51:59: rant
submission moves from a command-only path to "Agent auto-detects in normal
conversation, confirms with the user, then calls the submit_rant tool").
Both the daemon ``rant`` command and the ``submit_rant`` tool call
:func:`append_rant`, so behavior stays identical.
"""

from __future__ import annotations

import json
from datetime import datetime
from pathlib import Path


def append_rant(rants_log: Path, message: str, project: str = "") -> int:
"""Append a rant entry to ``rants_log``, sorted by timestamp.

Args:
rants_log: Path to rants.jsonl (e.g. ``~/.emrg/rants.jsonl``).
message: The rant body (already user-confirmed / polished).
project: Optional target project name (empty = EMRG itself).

Returns:
The new total rant count.
"""
# Field order: timestamp → project → status → progress → completed → message
# (project right after timestamp per user feedback; message last)
# Timestamp is daemon-authoritative local time (rant 2026-08-07T13:34Z):
# clients previously supplied timestamps — GUI sent new Date().toISOString()
# (UTC, 8h behind on UTC+8 hosts), TUI sent naive local time. A tz-aware
# local ISO timestamp (+08:00) is self-describing, sorts correctly, and is
# consistent regardless of which client submitted the rant.
entry: dict = {
"timestamp": datetime.now().astimezone().isoformat(),
"project": project,
"status": "pending",
"progress": None,
"completed": None,
}
# message last, so status fields stay visible when scanning the file
entry["message"] = message

rants_log.parent.mkdir(parents=True, exist_ok=True)

# Read existing rants, append new, sort by timestamp, rewrite sorted
rants: list[dict] = []
if rants_log.exists():
with open(rants_log, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
rants.append(json.loads(line))
except json.JSONDecodeError:
pass
rants.append(entry)
rants.sort(key=lambda r: r.get("timestamp", ""))

with open(rants_log, "w", encoding="utf-8") as f:
for r in rants:
f.write(json.dumps(r, ensure_ascii=False) + "\n")

return len(rants)
3 changes: 3 additions & 0 deletions emrg/server/tool_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ class ToolDefinition:

name: tool name exposed to the model
description: what the tool does (used by the model for routing)
purpose: human-friendly one-line purpose (used in logs/UI — what is
this tool for, in plain words; rant 2026-08-17T12:03:13)
parameters: JSON Schema dict for the tool's arguments
"""

name: str = ""
description: str = ""
purpose: str = ""
parameters: dict = field(default_factory=dict)


Expand Down
1 change: 1 addition & 0 deletions emrg/tools/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class BashTool(ToolExecutor):
def definition(self) -> ToolDefinition:
return ToolDefinition(
name="bash",
purpose="Execute a shell command and return its output (run tests, inspect files, git operations, etc.)",
description=(
"Execute a shell command and return stdout and stderr. "
"Use for running tests, git commands, listing files, "
Expand Down
1 change: 1 addition & 0 deletions emrg/tools/edit_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class EditTool(ToolExecutor):
def definition(self) -> ToolDefinition:
return ToolDefinition(
name="edit",
purpose="Precisely replace a text fragment in an existing file (shows diff)",
description=(
"Replace old_string with new_string in an existing file. "
"old_string must appear exactly once in the file — use the "
Expand Down
1 change: 1 addition & 0 deletions emrg/tools/glob_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class GlobTool(ToolExecutor):
def definition(self) -> ToolDefinition:
return ToolDefinition(
name="glob",
purpose="Find files by name pattern (e.g. '**/*.py')",
description=(
"Find files matching a glob pattern. "
"Supports standard glob patterns: *, ?, [seq], ** for recursive. "
Expand Down
1 change: 1 addition & 0 deletions emrg/tools/grep_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class GrepTool(ToolExecutor):
def definition(self) -> ToolDefinition:
return ToolDefinition(
name="grep",
purpose="Search file contents with a regex pattern",
description=(
"Search file contents for a regex pattern. "
"Returns matching lines prefixed with filename:line_number. "
Expand Down
1 change: 1 addition & 0 deletions emrg/tools/read_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ReadTool(ToolExecutor):
def definition(self) -> ToolDefinition:
return ToolDefinition(
name="read",
purpose="Read file content (line-numbered, chunked)",
description=(
"Read a file from the filesystem. Returns content with "
"line numbers prefixing each line (format: ' LINE_NUMBER\\tCONTENT'). "
Expand Down
Loading
Loading