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 @@ -42,7 +42,7 @@ What EMRG does today — capability statements, not a changelog.

- **TUI client** — markdown/diffs, slash commands, autocomplete, pickers, clipboard images (`vision` per model), ESC interrupt, CJK-aware input
- **Electron GUI** — streaming chat, tool-status cards, sessions, models, settings, onboarding, reconnect with daemon respawn; React + TypeScript; vitest
- **Scheduled tasks** — `TaskHandler` for any registered project: builtin and custom types, CRUD, hot reload
- **Scheduled tasks** — `TaskHandler` for any registered project: builtin and custom types, CRUD, hot reload, `when` early wake
- **Rant-driven evolution** — host feedback through `/rant` drives the self-improvement cycles
- **Memory** — project and session memory (YAML frontmatter + a Markdown index), compact/clear, a capped prompt footprint
- **Skills** — `.emrg/skills/` with progressive disclosure + an installable-skill catalog
Expand Down
17 changes: 17 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ A confined run cannot write your home directory, which is where `uv`, `pip` and

Inside a window that window's `interval` answers; outside every window and between windows the top-level one does, so one instant never has two answers. Times are **local time only** — read with `datetime.now()`, with no timezone field and no DST rule — so the windows follow the host's clock; there are no public holidays and no make-up workdays, only the weekdays a window names (`fri-mon` wraps around the week). A wait that would run past the next edge is **cut to it**: a 3-hour weekend cadence that begins at 06:59 wakes at 09:00 rather than sleeping through the window it was about to enter, and a restart inside a window does not drop the task to the out-of-window frequency. Two windows of one day may not overlap, a window crossing midnight is written as two windows, and every field is validated when the file is read — a bad window is refused with the value it refused and the shape it expects. The daemon's create/update path and the task listing carry the same shape (`effective_interval` and `next_window_boundary_in_seconds` in `list_tasks`); until a client can edit windows, hand-editing this file is how to set one, and the scheduler picks the change up at the task's next wake.

**A task that runs when there is something to do** (`when` in `~/.emrg/tasks.yml`): `interval` says how often a task runs on its own initiative, and a fixed cadence is the wrong instrument for a pulse — a task polling an inbox every four hours is hours late for a message that arrived a minute after its last run, and a task on a short interval burns a whole agent round to discover that nothing arrived. `when` is an optional mapping from a built-in condition name to a number of seconds — "while this holds, run the task again at most this long after its last run":

```yaml
- name: feishu-inbox
interval: 14400
when:
feishu: 300 # an @, an @all, or a direct message needs the host
- name: some-evolution-task
interval: 14400
when:
rant: 600 # this task's agent carries a rant that is not completed
```

It only ever advances a run: `interval` and `schedule` still decide the task's own cadence, `when` never suppresses a run the task would have taken anyway, and a record without `when` behaves exactly as before. The condition names are built in (`emrg/server/wake.py` holds the table) — a host writes a name and a number of seconds, never a command, a script or a function name, and an unknown name, a `0`, a negative number or a value under the `60`-second floor is refused with the value it refused and the shape it expects. A condition answers *holds*, *absent* or *cannot tell*; *cannot tell* — an unauthenticated `lark-cli`, an unreadable rant log — leaves the task asleep and logs the reason, because "could not read it" says nothing about whether the host is being asked for something.

Unlike `schedule`, this is a **poll**: the scheduler already sleeps for the task's own wait, so the shortest `when` value takes a slot inside that wait and each condition is consulted on its own clock. A poll that finds nothing re-arms the remainder of the task's own cadence rather than restarting it, so `next_run_at` keeps reporting the real run and a condition cannot push that run out. A wake that would land while the task is already running is harmless — the poll happens outside the cycle, so it neither interrupts the cycle nor marks anything read, and the condition is judged again next time. `feishu` keeps a watermark under `~/.emrg/wake-state/<task>.json` because Feishu reports message times to the minute and a time cannot be a cursor: consecutive polls overlap deliberately and a message id is what decides. The daemon's create/update path and its wire handlers carry the field, so editing another field in a client cannot drop it, and hand-editing the file remains how to set one.

---

## 🏗️ Architecture
Expand Down
8 changes: 7 additions & 1 deletion emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -3039,6 +3039,7 @@ async def _process_message(
description=msg.get("description"),
sandbox=msg.get("sandbox"),
schedule=msg.get("schedule"),
when=msg.get("when"),
)
if not ok:
await self._send(ws, {"type": "task_result", "error": res})
Expand All @@ -3054,7 +3055,12 @@ async def _process_message(
if not self._scheduler:
await self._send(ws, {"type": "task_result", "error": "scheduler not running"})
return
fields = {k: msg[k] for k in ("task_type", "agent", "interval", "enabled", "repo", "description", "sandbox", "schedule") if k in msg}
# This list is the wire's whole vocabulary: a key it omits is a field a
# client cannot edit, whatever the scheduler supports — which is how
# `schedule` was once silently dropped (#2490). `when` is here for the same
# reason `schedule` is (issue #2592), and a key the message omits still
# leaves that field alone.
fields = {k: msg[k] for k in ("task_type", "agent", "interval", "enabled", "repo", "description", "sandbox", "schedule", "when") if k in msg}
if "task_type" in fields:
fields["type"] = fields.pop("task_type")
ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields)
Expand Down
14 changes: 14 additions & 0 deletions emrg/server/rants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@
}


def default_rants_log() -> Path:
"""The ledger this installation writes — the one derivation of its path.

Two readers had spelled `config_dir() / "rants.jsonl"` for themselves: the
``submit_rant`` tool and the path policy. A third reader is arriving with the
scheduler's ``rant`` wake condition (issue #2592), and three copies of a path is how
a reader comes to watch a file nobody writes. The import is local because
``emrg.config`` is imported by nearly everything and this module is imported early.
"""
from emrg.config import config_dir

return config_dir() / "rants.jsonl"


def _normalize_rant(raw) -> dict | None:
"""Normalize one parsed line to the canonical 6-field dict.

Expand Down
Loading
Loading