From 4197a1b1132577b6bf3976ae0f01a1acd5c56840 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 5 Nov 2026 11:47:15 +0800 Subject: [PATCH] emrg: self-media becomes a built-in task type (trends -> topic -> draft -> archive) --- README.cn.md | 2 +- README.md | 2 +- emrg/server/scheduler.py | 1 + emrg/server/self_media_prompt.md | 132 +++++++++++++++++++++++ scripts/check-rant-citations.py | 1 + tests/test_prompt_templates.py | 1 + tests/test_scheduler.py | 174 ++++++++++++++++++++++++++++++- 7 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 emrg/server/self_media_prompt.md diff --git a/README.cn.md b/README.cn.md index bbbede12..d0162a15 100644 --- a/README.cn.md +++ b/README.cn.md @@ -47,7 +47,7 @@ EMRG 是一个关于*自主进化*的实验:一个能帮你写代码的 AI 智 |---|---| | **Electron GUI(主入口)** | 安装即用:首次启动引导配置 API Key;全部 `/` 指令可用;结果面板、Ask/Auto 模式、自进化可见化。GUI 配好即 TUI 可用 | | **全功能 TUI + 守护进程** | 流式 Markdown、`/` 自动补全、会话管理、快捷键——跑在常驻的 `emrgd` 守护进程上,随时重连 | -| **定时任务** | 内置任务类型(`evolution`、`paper`、`open-source`、`promote`、`journal`、`competition`、`ios-app`、`feishu`)加自定义类型(提示词模板存 `~/.emrg/task-templates/`)、GUI 设置页增删改 + 热加载、`/trigger` 随时手动触发 | +| **定时任务** | 内置任务类型(`evolution`、`paper`、`open-source`、`promote`、`journal`、`competition`、`ios-app`、`feishu`、`self-media`)加自定义类型(提示词模板存 `~/.emrg/task-templates/`)、GUI 设置页增删改 + 热加载、`/trigger` 随时手动触发 | | **100% 开源** | MIT 协议——没有围墙,没有厂商锁定。默认英文,提供中文版 | --- diff --git a/README.md b/README.md index ff107f77..766ef5ad 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ EMRG is an experiment in *autonomous self-improvement*: an AI agent that helps y |---|---| | **Electron GUI (main entry)** | Install & go: first-run wizard, all slash commands, results panel, Ask/Auto modes, visible self-evolution. GUI configured = TUI ready | | **Full-featured TUI + daemon** | Streaming markdown, `/` autocomplete, sessions, shortcuts — on a persistent `emrgd` daemon you can reconnect to anytime | -| **Scheduled tasks** | Built-in task types (`evolution`, `paper`, `open-source`, `promote`, `journal`, `competition`, `ios-app`, `feishu`) plus custom ones with prompt templates in `~/.emrg/task-templates/`, CRUD + hot reload from the GUI settings, `/trigger` any task on demand | +| **Scheduled tasks** | Built-in task types (`evolution`, `paper`, `open-source`, `promote`, `journal`, `competition`, `ios-app`, `feishu`, `self-media`) plus custom ones with prompt templates in `~/.emrg/task-templates/`, CRUD + hot reload from the GUI settings, `/trigger` any task on demand | | **100% open source** | MIT — no walled garden, no vendor lock-in. English default, Chinese version available | --- diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 358eab84..c4014cf1 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -199,6 +199,7 @@ def recovery_recipe(stash_message: str) -> str: "competition": "competition_prompt.md", "ios-app": "ios_app_prompt.md", "feishu": "feishu_prompt.md", + "self-media": "self_media_prompt.md", } # ── Task CRUD constants (rant 2026-08-12T18:23:15 P2) ───────────── diff --git a/emrg/server/self_media_prompt.md b/emrg/server/self_media_prompt.md new file mode 100644 index 00000000..9a2febab --- /dev/null +++ b/emrg/server/self_media_prompt.md @@ -0,0 +1,132 @@ +## Self-Media Task + +You are EMRG's self-media drafting module for **one channel of one platform**. **Every round runs +the same four steps, in this order — Read the trends → Choose the topic → Draft → Archive with its +readings — and the round ends where the fourth step ends.** + +### Current State +- Task: **{{ session_id }}** — also the name of the archive directory this task owns (§4) +- Target platform: **{{ task.platform }}** (from tasks.yml; an empty value is handled in §4, never + filled in by guesswork) +- Local source: `{{ source_dir }}` +- **Current time: `{{ timestamp }}`({{ current_time_human }})** — the anchor for the file name and + for what counts as current this round + +{% if task.extra_prompt %} +## Task-specific Instructions (extra_prompt from tasks.yml) + +Voice, audience, the account's positioning, standing subject preferences and anything that must not +be written about are read from here. A value this section does not give you is a `blocked` item for +the host — never invented, never defaulted. + +{{ task.extra_prompt }} +{% endif %} + +--- + +### 0. Scope — the round ends at a draft + +This task produces **a drafted, archived piece and its readings**. That is the whole mandate: + +- **No publishing.** Not to the platform, not through a scheduled post, not "just this one because it + is ready". Publication is a separate capability that needs the host's own decision, and a template + that writes this line is not the place it arrives from. +- **No account login, and no credentials.** This task never signs in to a platform, never reads or + stores a session cookie, token or password. A step that turns out to need a login is a `blocked` + item for the host, reported in the closing summary — never worked around. +- **One product per round.** A second piece is a second round, not a longer one: the round's readings + (§4) describe exactly one draft, and a file carrying two makes every number in it ambiguous. + +### 1. Read the trends — graded, and the round says which level it used + +```text +level "live" heat or trend data was actually read this round +level "fallback" it could not be read, and the product says so +``` + +- **Live.** Where heat data can really be read — a public trends page, an authorized API, a search + over recent posts — read it, and record **what** was read and **when**. A handful of signals is + this round's whole budget; the platform's entire corpus is not. +- **Fallback.** Where it cannot be read — no access, a login wall, a page that fails, a tool that is + not installed — **do not simulate it**. Choose the topic from domain common sense and mark the + product **`no real-time data was obtained`**, naming the reason (no access / blocked / unreadable). +- A round that reports a trend it never read is worse than a round with no live data. The second is + honest and the host can act on it; the first teaches the host to trust a guess, and the next real + signal is then read as one more guess. +- Absent live data is not a reason to skip the round: the fallback level exists precisely so a round + with no data still produces something the host can read. + +### 2. Choose the topic — one topic, and where it came from + +- **One topic.** Not a theme with several angles, not two subjects joined by "and". +- The product names **the source of the choice**: a live signal from §1, a standing question in the + domain, a theme `extra_prompt` sets, or a follow-up to an archived draft (named by its file). +- The test of a topic is whether a reader would be glad to have spent time on it. "It is easy to + write" is not a reason, and neither is "it is being discussed" — being discussed is what §1 + measures, not what §2 decides. +- State in one sentence **why this topic and not the runner-up**. That sentence is the round's + judgement; a summary of the candidate list is not one. + +### 3. Draft — three structures, exactly one chosen + +| Structure | Shape | It earns the choice when | +|---|---|---| +| **List-style** | a numbered set of points, each standing on its own | the value is **coverage**: several independent things a reader can take one at a time | +| **Single-point long form** | one claim, developed to the end, no detours | the value is **depth**: one thing is worth more than it looks, and a list would flatten it | +| **Narrative** | a sequence of events with a change between the start and the end | the value is **the change**: what was believed, what happened, what is believed now | + +- **Choose exactly one**, and **state why that one for this topic**. The reason names the topic's + value — depth, coverage, or a change — not the writer's mood or habit. +- The structure decides the shape and the length follows the structure and the topic. There is no + target word count: a list of three thin points is worse than one developed paragraph, and padding a + single point to a fixed length is how a good observation becomes a bad piece. +- Write for the audience `extra_prompt` names; absent that section, plain and specific beats clever. +- Keep the draft in one file. Revisions happen in it, not in a second file beside it. + +### 4. Archive, and record the round's readings + +- **Directory**: `~/.emrg/self-media/{{ session_id }}/` — this task's own directory. Two self-media + tasks never share one, so a channel's drafts stay readable as one channel's. +- **File name**: `--.md` — for example + `2026-11-05-xiaohongshu-first-server.md`. The date and the platform in the name are what make a + directory of drafts sortable and greppable a month later. **One file per round, created — never + appended to.** +- A record whose `config.platform` is empty is **not** fatal: the file name leaves the platform out, + and the round reports the missing config. What the round never does is invent a platform name to + fill it. +- The file opens with a readings block recording, at minimum: + +| Field | What it holds | +|---|---| +| `topic_source` | §2's answer — where the topic came from | +| `structure` | the one chosen in §3, and the reason it was chosen | +| `word_count` | the draft's own count, **measured** with a tool — never estimated | +| `trend_data` | `live` (with what was read and when) or `no real-time data was obtained` (with why) | +| `platform`, `date`, `task` | the channel this file belongs to, the round's date, and this task's name | + +- **A measured number, not a guess**: an estimated word count is not a reading, and a readings block + whose numbers are invented makes the honest fields beside them worthless. +- These readings are the round's output alongside the draft, and **they are not sent anywhere**. + Nothing leaves the machine in this round — no post, no message, no upload. + +### Closing summary + +Report, in this order: the §1 level and what was read; the topic and its source; the structure and +why; the archived file's path; the measured word count. + +A round that drafted nothing is a correct round — say which of these stopped it: no topic worth +writing, no readable trend at either level, a step that needed a login, or a missing `extra_prompt` +value it refused to invent. + +--- + +### Forbidden + +- 🛑 Never publish anything — no post, no scheduled post, no reply, no upload. The round ends at an archived draft (§0), and publication is the host's own decision +- 🛑 Never sign in to a platform account, and never read, store or log a credential. A step that needs a login is a `blocked` item, not a workaround +- 🛑 Never report a trend as read when it was not: §1's fallback level, marked `no real-time data was obtained`, is the honest answer and a usable one +- 🛑 Never fill a missing value with an invented one — a platform, an account, a source or a figure that `extra_prompt` did not give you is reported, never guessed +- 🛑 Never carry two products in one round: one file, one piece, one set of readings (§4) +- 🛑 Do not do multiple unrelated things in one round +- 🛑 **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** — not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` 第四条附则二; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) — a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- 🛑 **Never write, restore or introduce anything that triggers the real auto-upgrade chain** — not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` 第四条附则三; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/scripts/check-rant-citations.py b/scripts/check-rant-citations.py index af09c443..f9db6492 100644 --- a/scripts/check-rant-citations.py +++ b/scripts/check-rant-citations.py @@ -168,6 +168,7 @@ class reads **45 sites over 30 distinct timestamps in 9 of the 12 files**, and 4 "emrg/server/journal_prompt.md", "emrg/server/open_source_prompt.md", "emrg/server/promote_prompt.md", + "emrg/server/self_media_prompt.md", # a task template (#2714) "emrg/server/paper_prompt.md", "emrg/server/prompts/boss.j2", # rendered per `boss` call (#2467) "emrg/server/prompts/memory_compaction.j2", # rendered by daemon.COMPACTION_TEMPLATE diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py index deeba7e4..5de05c46 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -66,6 +66,7 @@ "role": "committer", "author_id": "argszero", "keywords": "demo, wiring", + "platform": "xiaohongshu", "extra_prompt": "EXTRA-PROMPT-MARKER", "allow_self_merge": True, } diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index ac938489..e15dffde 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1244,7 +1244,7 @@ def test_task_extra_prompt_conditional_all_templates(): extra = "插件开发(plugin)也是对本项目的合法贡献,不应被排除在贡献范围之外" heading = "Task-specific Instructions (extra_prompt from tasks.yml)" for name in ("evolution_prompt.md", "paper_prompt.md", "open_source_prompt.md", - "promote_prompt.md", "journal_prompt.md"): + "promote_prompt.md", "journal_prompt.md", "self_media_prompt.md"): tpl = env.from_string((server_dir / name).read_text(encoding="utf-8")) # positive: extra_prompt configured → injected near the top out = tpl.render(task={"extra_prompt": extra, "role": "committer"}, **base) @@ -4780,3 +4780,175 @@ def visit_Call(self, node): f"{method.split('_', 1)[1]} is called outside {method}() by {sorted(unexpected)} " f"— every open in this module asks its subject's kind first (issue #2095)" ) + + +# ── self-media task type (issue #2714) ───────────────────────────── + + +def _self_media_template_text() -> str: + """The built-in self-media prompt, read through the mapping rather than a path. + + Reading it through ``TASK_TEMPLATES`` is deliberate: a test that hard-codes + ``emrg/server/self_media_prompt.md`` keeps passing after the mapping is renamed, + which is the half of the pair that actually renders. + """ + from emrg.server.scheduler import TASK_TEMPLATES + + path = (Path(__file__).resolve().parent.parent / "emrg" / "server" + / TASK_TEMPLATES["self-media"]) + return path.read_text(encoding="utf-8") + + +def test_self_media_is_a_builtin_task_type(): + """The key, the file name and the file are one fact; either half alone renders nothing.""" + from emrg.server import scheduler as mod + + assert "self-media" in mod.TASK_TEMPLATES, ( + "self-media must be a built-in type: a tasks.yml record naming it has to work on an " + "installation that has no ~/.emrg/task-templates/ at all" + ) + assert mod.TASK_TEMPLATES["self-media"] == "self_media_prompt.md", ( + "the key spells the type with a hyphen and the file with underscores, like every " + "sibling in this mapping" + ) + path = (Path(__file__).resolve().parent.parent / "emrg" / "server" / "self_media_prompt.md") + assert path.exists(), f"the mapping names a file that does not exist: {path.name}" + assert path.stat().st_size > 0, "a template that renders nothing is not a built-in type" + + +def test_self_media_template_carries_its_four_steps(): + """The round's four steps, in order, with the rules that make each one checkable.""" + text = _self_media_template_text() + flat = " ".join(text.split()) + + steps = ["### 1. Read the trends", "### 2. Choose the topic", + "### 3. Draft", "### 4. Archive"] + positions = [flat.find(step) for step in steps] + assert all(position >= 0 for position in positions), ( + f"every step must have its own section; missing: " + f"{[s for s, p in zip(steps, positions) if p < 0]}" + ) + assert positions == sorted(positions), ( + "the steps are read in order — a round that drafts before it chooses a topic has " + "already decided what the piece is about" + ) + + for phrase, why in ( + ("no real-time data was obtained", + "§1's fallback level needs a fixed marker: a round without live data must still " + "produce a product, and the host must be able to tell that product apart from one " + "built on a signal"), + ("List-style", + "§3 must name all three structures — one missing is a structure no round can pick"), + ("Single-point long form", + "§3 must name all three structures — one missing is a structure no round can pick"), + ("Narrative", + "§3 must name all three structures — one missing is a structure no round can pick"), + ("state why that one for this topic", + "the chosen structure carries a reason: without it the choice is habit, and no " + "later round can learn which structure worked"), + ("--.md", + "§4's file-name rule is what makes a directory of drafts sortable a month later; " + "a name without the date or the platform cannot be searched for either"), + ("One file per round", "one round is one product (§0)"), + ("never appended to", + "a second round appending to the first one's file makes every reading in it describe " + "two pieces"), + ("`topic_source`", "§4 lists the readings the file must carry"), + ("`word_count`", "§4 lists the readings the file must carry"), + ("`trend_data`", "§4 lists the readings the file must carry"), + ("measured", "a count that was estimated is not a reading"), + ("No publishing", + "the round ends at a draft: publication is the host's decision, and a template that " + "leaves it implicit is how a task posts on its own"), + ("No account login", + "a task that signs in holds the host's credentials; §0 refuses that outright and " + "makes a login-gated step a blocked item instead"), + ): + assert phrase in flat, f"the self-media template must state that {why}; {phrase!r} is missing" + + +def test_self_media_builtin_cannot_be_shadowed_by_a_custom_template(tmp_path, monkeypatch): + """Built-in resolution wins over a same-named user template. + + Inverting the two lookups in ``_resolve_task_template`` reddens this: the host's local + file would then silently replace a prompt that ships with the version. + """ + from emrg.server import scheduler as mod + + (tmp_path / "task-templates").mkdir() + (tmp_path / "task-templates" / "self-media.md").write_text( + "# evil override\n", encoding="utf-8" + ) + monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) + + resolved = mod._resolve_task_template("self-media") + assert resolved.name == "self_media_prompt.md", ( + "a local self-media.md must not shadow the built-in prompt" + ) + assert "task-templates" not in str(resolved) + assert resolved.exists() + + +def test_self_media_record_is_creatable_and_unknown_types_still_fail(tmp_path): + """The point of the built-in registration: creatable with no user template on disk.""" + mod, orig = _p2_env(tmp_path) # tmp_path has NO task-templates/ dir + try: + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tmp_path / "tasks.yml" + ok, task = sched.task_create("media-task", "self-media", "emrg", 3600) + assert ok, task + assert task["type"] == "self-media" + # the guard this changes nothing about: an unknown type is still refused + ok, err = sched.task_create("other", "no-such-type", "emrg", 60) + assert not ok and "unknown task type" in err + finally: + mod.config_dir = orig + + +def test_self_media_template_renders_in_both_extra_prompt_states(): + """Positive AND negative: configured renders the section, absent leaves no residue.""" + import jinja2 + + env = jinja2.Environment(undefined=jinja2.Undefined) + template = env.from_string(_self_media_template_text()) + base = dict( + instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", + owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", + session_id="media-task", timestamp="20261105-114423", + current_time_human="2026-11-05 11:44", agent={"name": "emrg"}, + ) + extra = "voice: plain and specific; nothing about pricing" + + out = template.render(task={"extra_prompt": extra, "platform": "xiaohongshu"}, **base) + assert "Task-specific Instructions" in out + assert extra in out + assert "xiaohongshu" in out, "the platform reaches the round from tasks.yml" + assert "media-task" in out, "the archive directory is named after the task" + assert "{{" not in out, "an unrendered placeholder means the context lost a name" + + out2 = template.render(task={"platform": "xiaohongshu"}, **base) + assert "Task-specific Instructions" not in out2 + assert extra not in out2 + + +def test_self_media_is_listed_as_a_viewable_builtin(tmp_path): + """`builtin: true` plus a non-empty body is what the GUI's read-only viewer shows.""" + mod, orig = _p2_env(tmp_path) + try: + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tmp_path / "tasks.yml" + rows = {row["name"]: row for row in sched.list_templates()} + assert "self-media" in rows, "the GUI lists types from list_templates()" + row = rows["self-media"] + assert row["builtin"] is True + assert row["template"] == "self_media_prompt.md" + assert row["prompt"].strip(), "a built-in with an empty body cannot be viewed" + assert "no real-time data was obtained" in row["prompt"] + # and the CRUD verbs treat it as read-only, like every other built-in + ok, err = sched.template_update("self-media", "x") + assert not ok and "read-only" in err + ok, err = sched.template_delete("self-media") + assert not ok and "read-only" in err + finally: + mod.config_dir = orig