From bff4145ea12239a658549f6b759bb34c16f19465 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:38:56 +0800 Subject: [PATCH 01/10] feat(hosts): add pi coding agent adapter --- INSTALL-LATEST.md | 4 +- README.md | 5 +- SKILL.md | 1 + pyproject.toml | 1 + src/memu/hosts/generic/detect.py | 1 + src/memu/hosts/pi/BRIDGING_TASK.md | 81 ++++++++++++++++ src/memu/hosts/pi/INSTALL.md | 145 +++++++++++++++++++++++++++++ src/memu/hosts/pi/UNINSTALL.md | 31 ++++++ src/memu/hosts/pi/__init__.py | 5 + src/memu/hosts/pi/cli.py | 39 ++++++++ src/memu/hosts/pi/sessions.py | 54 +++++++++++ tests/test_host_generic.py | 4 + tests/test_host_sessions.py | 56 ++++++++++- tests/test_scheduling_windows.py | 22 ++++- 14 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 src/memu/hosts/pi/BRIDGING_TASK.md create mode 100644 src/memu/hosts/pi/INSTALL.md create mode 100644 src/memu/hosts/pi/UNINSTALL.md create mode 100644 src/memu/hosts/pi/__init__.py create mode 100644 src/memu/hosts/pi/cli.py create mode 100644 src/memu/hosts/pi/sessions.py diff --git a/INSTALL-LATEST.md b/INSTALL-LATEST.md index de1503b5..f532b65d 100644 --- a/INSTALL-LATEST.md +++ b/INSTALL-LATEST.md @@ -103,7 +103,7 @@ A bare "yes" / "ok" means **Use this version** — default to proceeding. ## Step 4 — set up memU You now have the latest binaries. Identify **which agent you are** and use its -binary — memU has seven host adapters: +binary — memU has nine host adapters: | You are | Your binary | | --- | --- | @@ -113,6 +113,8 @@ binary — memU has seven host adapters: | OpenClaw | `memu-openclaw` | | Hermes | `memu-hermes` | | WorkBuddy | `memu-workbuddy` | +| Cola | `memu-cola` | +| pi | `memu-pi` | | anything else | `memu-agent` — run `memu-agent detect` if unsure | Then print your host's packaged guide and follow it to the letter: diff --git a/README.md b/README.md index 7ce6ac41..97e0611a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ memU is a lightweight, agent-driven memory system that gives users a shared LLM ## Quick start -memU works with Codex, Claude Code, Cursor, OpenClaw, Hermes, WorkBuddy, Cola, and more. See [Host adapters](#host-adapters-memory-for-desktop-coding-agents). +memU works with Codex, Claude Code, Cursor, OpenClaw, Hermes, WorkBuddy, Cola, pi, and more. See [Host adapters](#host-adapters-memory-for-desktop-coding-agents). **Cross-device · Free · Unlimited · [View online](https://memu.so)** @@ -127,6 +127,7 @@ memU runs as a sidecar to a desktop agent, one binary per host. Each binds two s | Hermes Agent | `memu-hermes` | `~/.hermes/state.db` (SQLite, read-only) | `~/.hermes/SOUL.md` | | WorkBuddy | `memu-workbuddy` | `~/.workbuddy/projects//.jsonl` | `~/.workbuddy/SOUL.md` | | Cola | `memu-cola` | `~/.cola/sessions//.jsonl` | `~/.cola/memory-bank/MEMORY.md` | +| pi | `memu-pi` | `~/.pi/agent/sessions//.jsonl` | `~/.pi/agent/AGENTS.md` | | **any other agent** | `memu-agent` | found by `memu-agent detect` (JSONL dialect sniffed) | found by `detect` (AGENTS.md / CLAUDE.md / SOUL.md / …) | For agents without a dedicated binary, `memu-agent detect` probes the machine and reports per agent whether **memorization** works (a recognizable session log exists) and whether **retrieval** works (an instruction file exists to patch) — then the same verbs run against what it found. @@ -149,7 +150,7 @@ Once installed, your agent retrieves relevant memory automatically before answer ```bash memu-codex retrieve "What should I remember about this project?" -# or: memu-claude-code / memu-cursor / memu-openclaw / memu-hermes / memu-workbuddy / memu-agent +# or: memu-claude-code / memu-cursor / memu-openclaw / memu-hermes / memu-workbuddy / memu-cola / memu-pi / memu-agent ``` Install or invoke the CLI directly: diff --git a/SKILL.md b/SKILL.md index 9be0051c..58f173ed 100644 --- a/SKILL.md +++ b/SKILL.md @@ -54,6 +54,7 @@ executing this skill): | Hermes Agent | `memu-hermes` | | WorkBuddy | `memu-workbuddy` | | Cola | `memu-cola` | +| pi | `memu-pi` | | anything else | `memu-agent` | Unsure, or not in the table? You are `memu-agent`. Run: diff --git a/pyproject.toml b/pyproject.toml index 9c444337..fbaacfee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ memu-openclaw = "memu.hosts.openclaw.cli:main" memu-hermes = "memu.hosts.hermes.cli:main" memu-workbuddy = "memu.hosts.workbuddy.cli:main" memu-cola = "memu.hosts.cola.cli:main" +memu-pi = "memu.hosts.pi.cli:main" # The generic adapter: any agent without a dedicated binary. `memu-agent # detect` finds the session log and instruction file, then reports which of # the two seams (memorization / retrieval) work for that agent. diff --git a/src/memu/hosts/generic/detect.py b/src/memu/hosts/generic/detect.py index ab897915..0fb01439 100644 --- a/src/memu/hosts/generic/detect.py +++ b/src/memu/hosts/generic/detect.py @@ -40,6 +40,7 @@ """Instruction-file names the ecosystem's agents load into every session.""" DEDICATED = { + ".pi": "memu-pi", ".codex": "memu-codex", ".claude": "memu-claude-code", ".cursor": "memu-cursor", diff --git a/src/memu/hosts/pi/BRIDGING_TASK.md b/src/memu/hosts/pi/BRIDGING_TASK.md new file mode 100644 index 00000000..4ae64e3b --- /dev/null +++ b/src/memu/hosts/pi/BRIDGING_TASK.md @@ -0,0 +1,81 @@ +--- +name: {{task_doc_name}} +description: Register a scheduled pi run that bridges recent sessions into memU. +--- + +# Create the memU bridging task (pi) + +## Task identity + +- Current task name: `{{task_name}}` +- Former task names: {{former_task_names}} +- Names recognized during migration and removal: {{all_task_names}} + +The task runs pi headlessly and defaults to hourly at minute 0. Reuse an +existing cadence unless the user requested a change. + +## macOS and Linux + +Write the following line verbatim to +`~/.memu/hosts/pi/bridge-prompt.txt`: + +```text +Run the memU bridging pipeline. Do the four steps strictly in order; do not skip a step even if the previous one looks like it produced nothing. 1. LEFTOVERS. If ~/.memu/hosts/pi/jobs/ already contains job files, they are unfinished work from an earlier run (a crash, or the install itself) — process them exactly as step 3 describes, then run: memu-pi commit — and only then continue. 2. PREPARE. Run this exact command with bash: memu-pi prepare — it regenerates ~/.memu/hosts/pi/jobs/. If the command exits non-zero, stop and report the error. 3. SELF-EVOLVE. List ~/.memu/hosts/pi/jobs/*.txt and process them in ascending numeric order (1.txt, then 2.txt, …). The count changes every run — always glob and sort. If there are no job files, skip to step 4. For each job file: read it and follow its instructions to the letter. Each job is self-contained and already carries the concrete paths it needs. Emitting no files for a job is a valid outcome; do not invent content. 4. COMMIT. Run this exact command with bash: memu-pi commit — it commits whatever the jobs created or changed. If it exits non-zero, report the error. ON FAILURE. If step 2 or step 4 exited non-zero, run this once before you stop: memu-pi report error --stage remember --detail "" — that detail is all a memU engineer gets to work out what is broken on this machine, so be generous: which step, what you ran, what happened instead, what you already tried, and what you think the cause is. Write it as prose for a human, not as a transcript — do not paste the traceback or raw command output, which the CLI already reports on its own, and keep credentials, absolute paths, and memory or transcript text out of it. Ignore any failure of that command; it is never part of the run. Finish with a one-line summary: how many jobs ran (leftovers included) and what was committed. +``` + +If pi uses a custom session directory, replace only `memu-pi prepare` in that +file with `memu-pi prepare --session-dir `. + +Write `~/.memu/hosts/pi/bridge.sh` and make it executable: + +```sh +#!/bin/sh +DIR="$HOME/.memu/hosts/pi" +LOCK="$DIR/.bridge.lock" +if ! mkdir "$LOCK" 2>/dev/null; then + if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +180 2>/dev/null)" ]; then + rmdir "$LOCK" 2>/dev/null + mkdir "$LOCK" 2>/dev/null || exit 0 + else + exit 0 + fi +fi +trap 'rmdir "$LOCK" 2>/dev/null' EXIT INT TERM +export MEMU_BRIDGING_RUN=1 +pi -p "$(cat "$DIR/bridge-prompt.txt")" >> "$DIR/bridge.log" 2>&1 +``` + +Add the directories containing `pi` and `memu-pi` to a crontab `PATH` line, +then register the short entry below. Never inline the long prompt in crontab. + +```cron +PATH=::/usr/local/bin:/usr/bin:/bin +``` + +```cron +0 * * * * $HOME/.memu/hosts/pi/bridge.sh # {{task_name}} +``` + +Use launchd only if the user explicitly requests it; use `{{task_name}}` as its +label and invoke the same wrapper. + +## Windows + +Use the existing Task Scheduler helper, which writes the prompt and a +PowerShell wrapper to disk and registers a windowless task: + +```powershell +memu-pi schedule install +memu-pi schedule verify +memu-pi schedule status +``` + +The helper resolves `pi`, runs a headless authentication probe, and registers +`{{task_name}}`. `--interval ` changes the default 60-minute cadence. + +## Verify + +Trigger one run and inspect filesystem evidence: `bridge.log` grows and the +session manifest or job timestamps advance. Do not rely only on the agent's +summary. The scheduled pi tool process exports `PI_SESSION_ID`, so `prepare` +records that run as bridge-owned and does not mine it later. diff --git a/src/memu/hosts/pi/INSTALL.md b/src/memu/hosts/pi/INSTALL.md new file mode 100644 index 00000000..96132a99 --- /dev/null +++ b/src/memu/hosts/pi/INSTALL.md @@ -0,0 +1,145 @@ +# Install memU for pi + +## Task identity + +- Current task name: `{{task_name}}` +- Former task names: {{former_task_names}} +- Names recognized during migration and removal: {{all_task_names}} + +> **Audience: the agent.** Work top to bottom. Do not continue past a failing +> verify gate. Finish or abandon the run by reporting the outcome at the end. + +Installing memU for pi has three parts: configure the shared memory backend, +schedule pi to bridge new sessions, and add the retrieval skill to pi's global +instructions. + +## Part 1 — Install and configure memU + +```sh +pip install --upgrade memu-cli +memu-pi --help +``` + +`--upgrade` matters: an existing older package otherwise stays installed. If +`memu-pi` is missing, the installed package predates this adapter or its scripts +directory is not on `PATH`. + +If pi itself is missing, install it with its official npm package: + +```sh +npm install -g --ignore-scripts @earendil-works/pi-coding-agent +``` + +Create the shared memU configuration. If `SKILL.md` already ran `init`, reusing +the existing mode is correct and re-running is harmless: + +```sh +memu-pi init --cloud-api-key +``` + +Use bare `memu-pi init` for local memory. Never edit `~/.memu/config.env` by +hand. Inspect it with `memu-pi config show`; reuse an existing backend from +another host. Otherwise configure exactly one: + +```sh +memu-pi config --cloud --cloud-api-key +memu-pi config --local --db /absolute/path/memu.sqlite3 --embed-provider openai --embed-api-key +``` + +Give `--db` an absolute path. Shell-only environment variables do not reach a +scheduled run. + +### Verify Part 1 + +```sh +memu-pi doctor +pi -p "Reply with exactly: ok" +``` + +Both commands must exit cleanly. Zero retrieval hits is normal for a new store. +The pi probe proves a model and persistent credential work without an +interactive login. + +## Part 2 — Register the record bridge + +By default pi stores v3 JSONL sessions below `~/.pi/agent/sessions`. The adapter +keeps user and assistant text, routes tool calls and results to the full +transcript, and ignores session, compaction, model, and thinking metadata. + +If `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, or `settings.json` moves +the session directory, pass the final directory to `memu-pi prepare +--session-dir ` and use the same explicit path in the scheduled prompt. + +**Refresh an existing bridging registration before continuing.** Check cron or +launchd for `hosts/pi/bridge\.sh|memU bridging pipeline`, or Task Scheduler for +`{{task_name}}`. Record its cadence and remove **only** that registration, then +verify it is gone. An absent registration is the normal first-install case. +Reuse the recorded cadence unless the user requested a change. + +Follow the packaged scheduler procedure: + +```sh +memu-pi docs task +``` + +### Verify Part 2 + +```sh +memu-pi prepare +``` + +It must report a session count. Zero is correct when no new turns exist. + +## Part 3 — Install retrieval + +Pi loads one global context file from its agent directory. Normally that is +`~/.pi/agent/AGENTS.md`; if `AGENTS.override.md` already exists there, it takes +precedence, so target that file instead. A custom `PI_CODING_AGENT_DIR` moves +both the instruction and skills directories. + +Default installation: + +```sh +memu-pi install-instruction +``` + +Custom or override installation: + +```sh +memu-pi install-instruction --path /AGENTS.override.md --skills-dir /skills +``` + +The command creates `skills/memu-retrieve/SKILL.md` and adds one managed pointer +block to the selected global context file. Existing content is preserved; +re-running is idempotent. + +### Verify Part 3 + +```sh +memu-pi retrieve "smoke test" +``` + +Confirm the managed block occurs once, the skill exists, and a fresh pi session +loads the selected context file. Empty result lists are valid. + +## Report the outcome to memU + +Every gate passed: + +```sh +memu-pi report install +``` + +If the run stops early: + +```sh +memu-pi report error --stage install --detail "" +``` + +The report is best-effort. Do not include credentials, absolute paths, memory, +transcript text, or raw command output. + +## Done + +Tell the user which backend was selected, where the schedule was registered, +its cadence, and that retrieval takes effect in the next pi session. diff --git a/src/memu/hosts/pi/UNINSTALL.md b/src/memu/hosts/pi/UNINSTALL.md new file mode 100644 index 00000000..a7de3272 --- /dev/null +++ b/src/memu/hosts/pi/UNINSTALL.md @@ -0,0 +1,31 @@ +# Uninstall memU for pi + +## Task identity + +- Current task name: `{{task_name}}` +- Former task names: {{former_task_names}} +- Names recognized during migration and removal: {{all_task_names}} + +1. Remove only pi's memU schedule: + - cron/launchd: remove the entry invoking `~/.memu/hosts/pi/bridge.sh` or + labeled `{{task_name}}`; + - Windows: run `memu-pi schedule uninstall`, then confirm + `memu-pi schedule status` reports not registered. +2. Run `memu-pi remove-instruction`. If installation used custom `--path` or + `--skills-dir`, pass the same values. This removes only memU's managed block + and `memu-retrieve` skill; user content remains. +3. Keep `~/.memu/config.env`, its memory store, and + `~/.memu/hosts/pi/.session_manifest.pi.json` unless the user explicitly asks + to erase memory. Remove the other files under `~/.memu/hosts/pi/`. +4. Before removing the package, report the uninstall: + + ```sh + memu-pi report uninstall + ``` + + On failure, use `memu-pi report error --stage uninstall --detail ""`. Reports are best-effort and + must not contain credentials, absolute paths, command output, memory, or + transcript text. +5. Remove `memu-cli` only if no other host adapter uses it. The event spool and + shared memory configuration also stay while another host remains. diff --git a/src/memu/hosts/pi/__init__.py b/src/memu/hosts/pi/__init__.py new file mode 100644 index 00000000..0e98d61c --- /dev/null +++ b/src/memu/hosts/pi/__init__.py @@ -0,0 +1,5 @@ +"""The pi host adapter — ``memu-pi``.""" + +from memu.hosts.pi.sessions import PiTranscriptSource + +__all__ = ["PiTranscriptSource"] diff --git a/src/memu/hosts/pi/cli.py b/src/memu/hosts/pi/cli.py new file mode 100644 index 00000000..fc052cd0 --- /dev/null +++ b/src/memu/hosts/pi/cli.py @@ -0,0 +1,39 @@ +"""``memu-pi`` — memU's pi host adapter.""" + +from __future__ import annotations + +import sys + +from memu.hosts.host_cli import HostSpec, run +from memu.hosts.pi.sessions import AGENT_DIR, SESSION_DIR, PiTranscriptSource + +HOST = "pi" +AGENTS_MD = f"{AGENT_DIR}/AGENTS.md" +SKILLS_DIR = f"{AGENT_DIR}/skills" + +SPEC = HostSpec( + host=HOST, + display="pi", + package="memu.hosts.pi", + task_name="memu-bridging-pi", + source_factory=PiTranscriptSource, + session_dir=SESSION_DIR, + session_help="pi v3 JSONL session directory (one directory per encoded cwd)", + instruction_path=AGENTS_MD, + skills_dir=SKILLS_DIR, + schedule_backend="os", + schedule_command="pi -p {prompt}", + schedule_prepare_session_dir=True, + session_id_env="PI_SESSION_ID", + needs_headless_auth=True, + install_hint=" npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + auth_hint=" authenticate pi with /login or persist the API key used by its selected provider", +) + + +def main(argv: list[str] | None = None) -> int: + return run(SPEC, argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/memu/hosts/pi/sessions.py b/src/memu/hosts/pi/sessions.py new file mode 100644 index 00000000..a334ebe0 --- /dev/null +++ b/src/memu/hosts/pi/sessions.py @@ -0,0 +1,54 @@ +"""pi v3 session transcripts: ``~/.pi/agent/sessions//*.jsonl``.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import ClassVar + +from memu.hosts.base import RecordKind, TranscriptSource + +AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent") +SESSION_DIR = os.environ.get("PI_CODING_AGENT_SESSION_DIR", f"{AGENT_DIR}/sessions") + + +class PiTranscriptSource(TranscriptSource): + """Split pi's parent-linked message entries into conversation and tool tracks.""" + + name: ClassVar[str] = "pi" + + def __init__(self, session_dir: str | Path = SESSION_DIR) -> None: + self._root = Path(os.path.expanduser(str(session_dir))) + + def root(self) -> Path: + return self._root + + def classify(self, record: str) -> RecordKind: + try: + entry = json.loads(record) + except json.JSONDecodeError: + return RecordKind.OTHER + if not isinstance(entry, dict) or entry.get("type") != "message": + return RecordKind.OTHER + + message = entry.get("message") + if not isinstance(message, dict): + return RecordKind.OTHER + role = message.get("role") + if role in {"toolResult", "bashExecution"}: + return RecordKind.TOOL + if role not in {"user", "assistant"}: + return RecordKind.OTHER + + content = message.get("content") + if isinstance(content, str): + return RecordKind.MESSAGE + if not isinstance(content, list): + return RecordKind.OTHER + block_types = {block.get("type") for block in content if isinstance(block, dict)} + if "text" in block_types: + return RecordKind.MESSAGE + if "toolCall" in block_types: + return RecordKind.TOOL + return RecordKind.OTHER diff --git a/tests/test_host_generic.py b/tests/test_host_generic.py index 10b022bd..779750eb 100644 --- a/tests/test_host_generic.py +++ b/tests/test_host_generic.py @@ -122,6 +122,10 @@ def test_detect_points_dedicated_hosts_at_their_binary(tmp_path: pathlib.Path) - (root / "AGENTS.md").write_text("# rules\n", encoding="utf-8") assert "memu-codex" in render(probe(root)) + pi_root = tmp_path / ".pi" + pi_root.mkdir() + assert "memu-pi" in render(probe(pi_root)) + def test_scan_home_finds_only_plausible_agents(tmp_path: pathlib.Path) -> None: _agent_dir(tmp_path, sessions=True, instructions=True) diff --git a/tests/test_host_sessions.py b/tests/test_host_sessions.py index 116451f8..ab79a283 100644 --- a/tests/test_host_sessions.py +++ b/tests/test_host_sessions.py @@ -1,6 +1,6 @@ """Each host's record seam: does classify() slice its log the way that host writes it? -One test module per invariant class, five hosts. The fixtures are hand-written +The fixtures are hand-written records in each host's real on-disk shape (see the session-location table in ADR 0010); if a host changes its log format, the fixture — not the pipeline — is what these tests localize the break to. @@ -23,6 +23,7 @@ from memu.hosts.cursor.sessions import CursorTranscriptSource from memu.hosts.hermes.sessions import HermesTranscriptSource, state_db_path from memu.hosts.openclaw.sessions import OpenClawTranscriptSource +from memu.hosts.pi.sessions import PiTranscriptSource from memu.hosts.workbuddy.sessions import WorkBuddyTranscriptSource @@ -30,6 +31,59 @@ def _line(entry: dict) -> str: return json.dumps(entry) +# ── pi ──────────────────────────────────────────────────────────────────────── + + +def test_pi_classifies_conversation_and_tool_rows() -> None: + source = PiTranscriptSource() + user = {"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]}} + narrated_tool = { + "type": "message", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Running it."}, {"type": "toolCall", "name": "bash"}], + }, + } + tool_call = { + "type": "message", + "message": { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hmm"}, {"type": "toolCall", "name": "bash"}], + }, + } + tool_result = {"type": "message", "message": {"role": "toolResult", "content": []}} + bash_execution = {"type": "message", "message": {"role": "bashExecution", "command": "pwd"}} + + assert source.classify(_line(user)) is RecordKind.MESSAGE + assert source.classify(_line(narrated_tool)) is RecordKind.MESSAGE + assert source.classify(_line(tool_call)) is RecordKind.TOOL + assert source.classify(_line(tool_result)) is RecordKind.TOOL + assert source.classify(_line(bash_execution)) is RecordKind.TOOL + + +def test_pi_drops_v3_metadata_and_thinking_only_rows() -> None: + source = PiTranscriptSource() + assert source.classify(_line({"type": "session", "version": 3, "id": "s", "cwd": "/workspace"})) is RecordKind.OTHER + assert source.classify(_line({"type": "compaction", "summary": "old work"})) is RecordKind.OTHER + assert ( + source.classify(_line({"type": "message", "message": {"role": "assistant", "content": [{"type": "thinking"}]}})) + is RecordKind.OTHER + ) + assert source.classify("not json") is RecordKind.OTHER + + +def test_pi_discovers_sessions_across_encoded_working_directories(tmp_path: pathlib.Path) -> None: + older = tmp_path / "--Users-a-one--" / "older.jsonl" + newer = tmp_path / "--Users-a-two--" / "newer.jsonl" + older.parent.mkdir() + newer.parent.mkdir() + older.write_text("{}\n", encoding="utf-8") + newer.write_text("{}\n", encoding="utf-8") + os.utime(older, (1, 1)) + os.utime(newer, (2, 2)) + assert PiTranscriptSource(tmp_path).discover() == [newer, older] + + # ── Cola ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index ea22c383..a624a4d9 100644 --- a/tests/test_scheduling_windows.py +++ b/tests/test_scheduling_windows.py @@ -30,10 +30,11 @@ from memu.hosts.hermes.cli import SPEC as HERMES from memu.hosts.host_cli import ScheduleBackend, build_parser, run from memu.hosts.openclaw.cli import SPEC as OPENCLAW +from memu.hosts.pi.cli import SPEC as PI from memu.hosts.scheduling import prompt, windows from memu.hosts.workbuddy.cli import SPEC as WORKBUDDY -SPECS = (CLAUDE, CURSOR, HERMES, CODEX, OPENCLAW, WORKBUDDY, COLA, GENERIC) +SPECS = (CLAUDE, CURSOR, HERMES, CODEX, OPENCLAW, WORKBUDDY, COLA, PI, GENERIC) EXPECTED_TASK_NAMES = { "claude-code": ("memu-bridging-claude-code", ("memu-remember-claude-code",)), @@ -43,6 +44,7 @@ "openclaw": ("memu-bridging-openclaw", ("memu-remember", "memu-bridging")), "workbuddy": ("memu-bridging-workbuddy", ()), "cola": ("memu-bridging-cola", ("memu-bridging", "memU 记忆桥接")), + "pi": ("memu-bridging-pi", ()), "agent": ("memu-bridging-agent", ()), } @@ -444,7 +446,17 @@ def test_hermes_pipeline_prompt_matches_the_bridging_doc() -> None: assert doc_prompt == prompt.bridging_pipeline_prompt(HERMES) -@pytest.mark.parametrize("pkg", ["claude_code", "cursor", "hermes", "generic"]) +def test_pi_pipeline_prompt_matches_the_bridging_doc() -> None: + from importlib.resources import files + + doc = (files("memu.hosts.pi") / "BRIDGING_TASK.md").read_text(encoding="utf-8") + doc_prompt = next( + line.strip() for line in doc.splitlines() if line.strip().startswith("Run the memU bridging pipeline.") + ) + assert doc_prompt == prompt.bridging_pipeline_prompt(PI) + + +@pytest.mark.parametrize("pkg", ["claude_code", "cursor", "hermes", "pi", "generic"]) def test_bridging_doc_cron_entries_stay_short(pkg: str) -> None: # The bug class behind memU#591: an inlined pipeline prompt pushed the guide's # crontab entry past cron's ~1KB line buffer, so every tick died mid-quote @@ -484,6 +496,7 @@ def test_hermes_guide_migrates_native_job_before_os_registration() -> None: ("openclaw", "memu-openclaw", ".cron_job.openclaw.json"), ("workbuddy", "memu-workbuddy", "WorkBuddy's automation list"), ("cola", "memu-cola", "{{all_task_names}}"), + ("pi", "memu-pi", r"hosts/pi/bridge\.sh|memU bridging pipeline"), ], ) def test_install_refreshes_existing_bridge_before_registration(pkg: str, binary: str, identity: str) -> None: @@ -570,6 +583,11 @@ def test_openclaw_task_recreates_confirmed_bridge() -> None: ), ("hosts/agent/bridge\\.sh|memU bridging pipeline", "memu-agent prepare --session-dir …"), ), + ( + "pi", + ("$HOME/.memu/hosts/pi/bridge.sh", "{{task_name}}"), + ("hosts/pi/bridge.sh", "schedule uninstall"), + ), ], ) def test_os_scheduler_identity_docs_stay_aligned( From b3642b2797eb5de6ea7e09a074c56b4cbe64f914 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:18:28 +0800 Subject: [PATCH 02/10] fix(scheduling): launch npm shims under S4U --- src/memu/hosts/claude_code/BRIDGING_TASK.md | 4 +- src/memu/hosts/cursor/BRIDGING_TASK.md | 4 +- src/memu/hosts/hermes/BRIDGING_TASK.md | 4 +- src/memu/hosts/pi/BRIDGING_TASK.md | 2 + src/memu/hosts/scheduling/windows.py | 41 +++++++++++++-- tests/test_scheduling_windows.py | 57 +++++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/memu/hosts/claude_code/BRIDGING_TASK.md b/src/memu/hosts/claude_code/BRIDGING_TASK.md index 630a713f..09160406 100644 --- a/src/memu/hosts/claude_code/BRIDGING_TASK.md +++ b/src/memu/hosts/claude_code/BRIDGING_TASK.md @@ -186,7 +186,7 @@ removable by name: ``` memu-claude-code schedule install # register the hourly task -memu-claude-code schedule verify # prove it resolves + authenticates +memu-claude-code schedule verify # registration + current-process auth only memu-claude-code schedule status # last run / next run memu-claude-code schedule uninstall # remove it ``` @@ -198,6 +198,8 @@ it (nothing long ever touches the command line), bakes in the absolute path to catches up a run missed while the machine was off. `--interval ` changes the cadence (default 60). +`schedule verify` does not trigger the S4U task and is not end-to-end proof. + Because the scheduled run needs a standalone, headless-authenticated `claude`, `install` **refuses with guidance** if `claude` isn't on `PATH` or can't authenticate without a browser. That is the memU#538 verify gate: better to fail at diff --git a/src/memu/hosts/cursor/BRIDGING_TASK.md b/src/memu/hosts/cursor/BRIDGING_TASK.md index 08cddf56..fd0d2843 100644 --- a/src/memu/hosts/cursor/BRIDGING_TASK.md +++ b/src/memu/hosts/cursor/BRIDGING_TASK.md @@ -179,7 +179,7 @@ helper instead — every install identical, removable by name: ``` memu-cursor schedule install # register the hourly task -memu-cursor schedule verify # prove it resolves + authenticates +memu-cursor schedule verify # registration + current-process auth only memu-cursor schedule status # last run / next run memu-cursor schedule uninstall # remove it ``` @@ -191,6 +191,8 @@ it (nothing long ever touches the command line), bakes in the absolute path to a run missed while the machine was off. `--interval ` changes the cadence (default 60). +`schedule verify` does not trigger the S4U task and is not end-to-end proof. + Cursor-specific facts, all field-verified on real Windows 11: - **Run `schedule install` from a terminal opened *after* installing diff --git a/src/memu/hosts/hermes/BRIDGING_TASK.md b/src/memu/hosts/hermes/BRIDGING_TASK.md index 9df166e6..ebcbd60a 100644 --- a/src/memu/hosts/hermes/BRIDGING_TASK.md +++ b/src/memu/hosts/hermes/BRIDGING_TASK.md @@ -172,7 +172,7 @@ native-job removal at the start of Step 2, then use the shared helper: ``` memu-hermes schedule install # register the hourly OS task -memu-hermes schedule verify # prove a resolvable CLI +memu-hermes schedule verify # registration + current-process auth only memu-hermes schedule status # last run / next run memu-hermes schedule uninstall # remove it ``` @@ -187,6 +187,8 @@ Unlike Claude Code or Cursor, Hermes ships its client and CLI together and uses one runtime/configuration; there is no separate CLI install or headless-auth step. +`schedule verify` does not trigger the S4U task and is not end-to-end proof. + After a run, check filesystem traces rather than trusting its summary: `~/.memu/hosts/hermes/jobs/` timestamps and the session manifest must advance. diff --git a/src/memu/hosts/pi/BRIDGING_TASK.md b/src/memu/hosts/pi/BRIDGING_TASK.md index 4ae64e3b..8f7fa922 100644 --- a/src/memu/hosts/pi/BRIDGING_TASK.md +++ b/src/memu/hosts/pi/BRIDGING_TASK.md @@ -72,6 +72,8 @@ memu-pi schedule status The helper resolves `pi`, runs a headless authentication probe, and registers `{{task_name}}`. `--interval ` changes the default 60-minute cadence. +`schedule verify` checks registration and authentication only; it does not run +the S4U task and is not end-to-end proof. ## Verify diff --git a/src/memu/hosts/scheduling/windows.py b/src/memu/hosts/scheduling/windows.py index a0e68133..d5cb5cfc 100644 --- a/src/memu/hosts/scheduling/windows.py +++ b/src/memu/hosts/scheduling/windows.py @@ -213,6 +213,21 @@ def _resolve_agent(spec: HostSpec) -> str | None: return shutil.which(_agent_binary(spec)) +def _resolve_scheduled_agent(agent_path: str) -> str | None: + """Return a launcher PowerShell can invoke directly from an S4U task. + + npm exposes commands as sibling ``.cmd`` and ``.ps1`` shims on Windows. + ``shutil.which`` returns the batch shim, but PowerShell's S4U process cannot + reliably launch it. Use npm's native PowerShell shim instead; if a batch-only + launcher has no such companion, refuse to register a task that cannot wake. + """ + path = Path(agent_path) + if path.suffix.lower() not in {".cmd", ".bat"}: + return agent_path + companion = path.with_suffix(".ps1") + return str(companion) if companion.is_file() else None + + def _authenticates(spec: HostSpec, agent_path: str, workdir: Path) -> tuple[bool, str]: """Does a cold headless run authenticate? (memU#538 Symptom B.) @@ -294,6 +309,15 @@ def install(spec: HostSpec, layout: Layout, *, interval_minutes: int = DEFAULT_I file=sys.stderr, ) return 1 + scheduled_agent_path = _resolve_scheduled_agent(agent_path) + if scheduled_agent_path is None: + print( + f"error: `{_agent_binary(spec)}` resolves to the batch launcher '{agent_path}', but no sibling " + "PowerShell shim exists. Reinstall the npm package so its .ps1 shim is generated; the S4U task " + "cannot reliably launch .cmd/.bat files.", + file=sys.stderr, + ) + return 1 layout.base.mkdir(parents=True, exist_ok=True) if (rc := _auth_gate(spec, agent_path, layout.base)) != 0: return rc @@ -321,7 +345,8 @@ def install(spec: HostSpec, layout: Layout, *, interval_minutes: int = DEFAULT_I # unless it sees a BOM, which would mangle a non-ASCII path (e.g. a CJK username) # baked into the wrapper. The BOM makes both 5.1 and 7 decode it as UTF-8. wrapper.write_text( - wrapper_script(agent_path, spec.schedule_command, prompt_file, log_file, path_dirs), encoding="utf-8-sig" + wrapper_script(scheduled_agent_path, spec.schedule_command, prompt_file, log_file, path_dirs), + encoding="utf-8-sig", ) proc = _run_powershell(register_script(spec.task_name, wrapper, interval_minutes, layout.base)) @@ -338,7 +363,7 @@ def install(spec: HostSpec, layout: Layout, *, interval_minutes: int = DEFAULT_I ) print(f"registered '{spec.task_name}' — runs every {interval_minutes} min, hidden, catches up if missed") print(f" wrapper: {wrapper}") - print(f" verify it can actually run: {spec.binary} schedule verify") + print(f" check registration and current-process headless auth: {spec.binary} schedule verify") return 0 @@ -392,7 +417,7 @@ def status(spec: HostSpec, layout: Layout) -> int: def verify(spec: HostSpec, layout: Layout) -> int: - """Prove one task is registered and its agent CLI can run headless. + """Check registration and a headless agent run from the current process. Deliberately does not trigger a full pipeline run (that would memorize real sessions as a side effect); it checks the things that silently break the @@ -423,6 +448,12 @@ def verify(spec: HostSpec, layout: Layout) -> int: if agent_path is None: print(f"error: `{_agent_binary(spec)}` is no longer on PATH (memU#538 Symptom A)", file=sys.stderr) return 1 + if _resolve_scheduled_agent(agent_path) is None: + print( + f"error: `{_agent_binary(spec)}` resolves to a .cmd/.bat launcher without its sibling .ps1 shim", + file=sys.stderr, + ) + return 1 layout.base.mkdir(parents=True, exist_ok=True) if (rc := _auth_gate(spec, agent_path, layout.base)) != 0: return rc @@ -431,7 +462,9 @@ def verify(spec: HostSpec, layout: Layout) -> int: if spec.needs_headless_auth: print(" its headless-auth probe also passed; credentials must remain persistent for the S4U run") print( - " after the next scheduled run, confirm it did work by traces, not its summary:\n" + " this does NOT run the S4U task; trigger the registered task and confirm it by traces:\n" + f" - {layout.base / LOG_NAME} grew,\n" + " - the host created a new session,\n" f" - {layout.jobs} timestamps advanced, and\n" f" - {layout.session_manifest} moved" ) diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index a624a4d9..176a7dc4 100644 --- a/tests/test_scheduling_windows.py +++ b/tests/test_scheduling_windows.py @@ -85,6 +85,44 @@ def test_wrapper_keeps_prompt_off_the_command_line(tmp_path: Path) -> None: assert "$env:Path = 'C:\\bin;C:\\memu;' + $env:Path" in text +def test_install_uses_powershell_companion_for_npm_cmd_shim(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + cmd_shim = bin_dir / "pi.CMD" + ps_shim = bin_dir / "pi.ps1" + cmd_shim.write_text("@ECHO off\r\n", encoding="utf-8") + ps_shim.write_text("#!/usr/bin/env pwsh\n", encoding="utf-8") + + monkeypatch.setattr(windows.platform, "system", lambda: "Windows") + monkeypatch.setattr(windows, "_resolve_agent", lambda spec: str(cmd_shim)) + monkeypatch.setattr(windows, "_auth_gate", lambda spec, path, workdir: 0) + monkeypatch.setattr(windows.shutil, "which", lambda binary: None) + monkeypatch.setattr( + windows, + "_run_powershell", + lambda script: subprocess.CompletedProcess([], 0, "", ""), + ) + + layout = Layout.default(host=PI.host, base=tmp_path / "host") + assert windows.install(PI, layout) == 0 + + wrapper = (layout.base / windows.WRAPPER_NAME).read_text(encoding="utf-8-sig") + assert f"& '{ps_shim}' -p $prompt" in wrapper + assert str(cmd_shim) not in wrapper + + +def test_install_rejects_batch_launcher_without_powershell_companion( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + cmd_shim = tmp_path / "pi.cmd" + cmd_shim.write_text("@ECHO off\r\n", encoding="utf-8") + monkeypatch.setattr(windows.platform, "system", lambda: "Windows") + monkeypatch.setattr(windows, "_resolve_agent", lambda spec: str(cmd_shim)) + + assert windows.install(PI, Layout.default(host=PI.host, base=tmp_path / "host")) == 1 + assert "no sibling PowerShell shim" in capsys.readouterr().err + + def test_register_script_is_canonical_and_hardened() -> None: script = windows.register_script("memu-bridging-claude-code", Path("C:\\w\\memu-bridge.ps1"), 60, Path("C:\\w")) assert "memu-bridging-claude-code" in script @@ -222,6 +260,25 @@ def run(script: str) -> subprocess.CompletedProcess[str]: assert "schedule install" in capsys.readouterr().err +def test_verify_says_it_did_not_run_the_scheduled_task( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(windows.platform, "system", lambda: "Windows") + monkeypatch.setattr( + windows, + "_run_powershell", + lambda script: subprocess.CompletedProcess([], 0, "registered", ""), + ) + monkeypatch.setattr(windows, "_resolve_agent", lambda spec: "C:\\bin\\pi.exe") + monkeypatch.setattr(windows, "_auth_gate", lambda spec, path, workdir: 0) + + assert windows.verify(PI, Layout.default(host=PI.host, base=tmp_path)) == 0 + out = capsys.readouterr().out + assert "does NOT run the S4U task" in out + assert "bridge.log" in out + assert "new session" in out + + def test_claude_uninstall_removes_only_known_identities(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: scripts: list[str] = [] monkeypatch.setattr(windows.platform, "system", lambda: "Windows") From a23724920685aac698d6e160af019c4442d6c0f8 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:40:09 +0800 Subject: [PATCH 03/10] chore(pi): keep scheduler docs scoped --- src/memu/hosts/claude_code/BRIDGING_TASK.md | 4 +--- src/memu/hosts/cursor/BRIDGING_TASK.md | 4 +--- src/memu/hosts/hermes/BRIDGING_TASK.md | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/memu/hosts/claude_code/BRIDGING_TASK.md b/src/memu/hosts/claude_code/BRIDGING_TASK.md index 09160406..630a713f 100644 --- a/src/memu/hosts/claude_code/BRIDGING_TASK.md +++ b/src/memu/hosts/claude_code/BRIDGING_TASK.md @@ -186,7 +186,7 @@ removable by name: ``` memu-claude-code schedule install # register the hourly task -memu-claude-code schedule verify # registration + current-process auth only +memu-claude-code schedule verify # prove it resolves + authenticates memu-claude-code schedule status # last run / next run memu-claude-code schedule uninstall # remove it ``` @@ -198,8 +198,6 @@ it (nothing long ever touches the command line), bakes in the absolute path to catches up a run missed while the machine was off. `--interval ` changes the cadence (default 60). -`schedule verify` does not trigger the S4U task and is not end-to-end proof. - Because the scheduled run needs a standalone, headless-authenticated `claude`, `install` **refuses with guidance** if `claude` isn't on `PATH` or can't authenticate without a browser. That is the memU#538 verify gate: better to fail at diff --git a/src/memu/hosts/cursor/BRIDGING_TASK.md b/src/memu/hosts/cursor/BRIDGING_TASK.md index fd0d2843..08cddf56 100644 --- a/src/memu/hosts/cursor/BRIDGING_TASK.md +++ b/src/memu/hosts/cursor/BRIDGING_TASK.md @@ -179,7 +179,7 @@ helper instead — every install identical, removable by name: ``` memu-cursor schedule install # register the hourly task -memu-cursor schedule verify # registration + current-process auth only +memu-cursor schedule verify # prove it resolves + authenticates memu-cursor schedule status # last run / next run memu-cursor schedule uninstall # remove it ``` @@ -191,8 +191,6 @@ it (nothing long ever touches the command line), bakes in the absolute path to a run missed while the machine was off. `--interval ` changes the cadence (default 60). -`schedule verify` does not trigger the S4U task and is not end-to-end proof. - Cursor-specific facts, all field-verified on real Windows 11: - **Run `schedule install` from a terminal opened *after* installing diff --git a/src/memu/hosts/hermes/BRIDGING_TASK.md b/src/memu/hosts/hermes/BRIDGING_TASK.md index ebcbd60a..9df166e6 100644 --- a/src/memu/hosts/hermes/BRIDGING_TASK.md +++ b/src/memu/hosts/hermes/BRIDGING_TASK.md @@ -172,7 +172,7 @@ native-job removal at the start of Step 2, then use the shared helper: ``` memu-hermes schedule install # register the hourly OS task -memu-hermes schedule verify # registration + current-process auth only +memu-hermes schedule verify # prove a resolvable CLI memu-hermes schedule status # last run / next run memu-hermes schedule uninstall # remove it ``` @@ -187,8 +187,6 @@ Unlike Claude Code or Cursor, Hermes ships its client and CLI together and uses one runtime/configuration; there is no separate CLI install or headless-auth step. -`schedule verify` does not trigger the S4U task and is not end-to-end proof. - After a run, check filesystem traces rather than trusting its summary: `~/.memu/hosts/hermes/jobs/` timestamps and the session manifest must advance. From 46b92f27202f61ba7c16a442132be2d57238b99b Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:41:40 +0800 Subject: [PATCH 04/10] fix(pi): invoke scheduler through CLI name --- src/memu/hosts/pi/INSTALL.md | 11 +-------- src/memu/hosts/scheduling/windows.py | 37 +++------------------------- tests/test_scheduling_windows.py | 20 +++++---------- 3 files changed, 11 insertions(+), 57 deletions(-) diff --git a/src/memu/hosts/pi/INSTALL.md b/src/memu/hosts/pi/INSTALL.md index 96132a99..e03fa468 100644 --- a/src/memu/hosts/pi/INSTALL.md +++ b/src/memu/hosts/pi/INSTALL.md @@ -24,12 +24,6 @@ memu-pi --help `memu-pi` is missing, the installed package predates this adapter or its scripts directory is not on `PATH`. -If pi itself is missing, install it with its official npm package: - -```sh -npm install -g --ignore-scripts @earendil-works/pi-coding-agent -``` - Create the shared memU configuration. If `SKILL.md` already ran `init`, reusing the existing mode is correct and re-running is harmless: @@ -53,12 +47,9 @@ scheduled run. ```sh memu-pi doctor -pi -p "Reply with exactly: ok" ``` -Both commands must exit cleanly. Zero retrieval hits is normal for a new store. -The pi probe proves a model and persistent credential work without an -interactive login. +The command must exit cleanly. Zero retrieval hits is normal for a new store. ## Part 2 — Register the record bridge diff --git a/src/memu/hosts/scheduling/windows.py b/src/memu/hosts/scheduling/windows.py index d5cb5cfc..0ea0fa6c 100644 --- a/src/memu/hosts/scheduling/windows.py +++ b/src/memu/hosts/scheduling/windows.py @@ -105,9 +105,9 @@ def wrapper_script( """The PowerShell wrapper the scheduled task runs. It re-establishes ``PATH`` (Task Scheduler does not inherit the interactive - shell's), reads the prompt from a file, and runs the agent. Absolute paths are - baked in at install time — the #530 "the scheduler's PATH is not your shell's" - capture, ported to Windows. + shell's), reads the prompt from a file, and runs the agent. Required search + paths are baked in at install time — the #530 "the scheduler's PATH is not + your shell's" capture, ported to Windows. """ path_prefix = ";".join(path_dirs) return "\n".join([ @@ -213,21 +213,6 @@ def _resolve_agent(spec: HostSpec) -> str | None: return shutil.which(_agent_binary(spec)) -def _resolve_scheduled_agent(agent_path: str) -> str | None: - """Return a launcher PowerShell can invoke directly from an S4U task. - - npm exposes commands as sibling ``.cmd`` and ``.ps1`` shims on Windows. - ``shutil.which`` returns the batch shim, but PowerShell's S4U process cannot - reliably launch it. Use npm's native PowerShell shim instead; if a batch-only - launcher has no such companion, refuse to register a task that cannot wake. - """ - path = Path(agent_path) - if path.suffix.lower() not in {".cmd", ".bat"}: - return agent_path - companion = path.with_suffix(".ps1") - return str(companion) if companion.is_file() else None - - def _authenticates(spec: HostSpec, agent_path: str, workdir: Path) -> tuple[bool, str]: """Does a cold headless run authenticate? (memU#538 Symptom B.) @@ -309,15 +294,7 @@ def install(spec: HostSpec, layout: Layout, *, interval_minutes: int = DEFAULT_I file=sys.stderr, ) return 1 - scheduled_agent_path = _resolve_scheduled_agent(agent_path) - if scheduled_agent_path is None: - print( - f"error: `{_agent_binary(spec)}` resolves to the batch launcher '{agent_path}', but no sibling " - "PowerShell shim exists. Reinstall the npm package so its .ps1 shim is generated; the S4U task " - "cannot reliably launch .cmd/.bat files.", - file=sys.stderr, - ) - return 1 + scheduled_agent_path = _agent_binary(spec) layout.base.mkdir(parents=True, exist_ok=True) if (rc := _auth_gate(spec, agent_path, layout.base)) != 0: return rc @@ -448,12 +425,6 @@ def verify(spec: HostSpec, layout: Layout) -> int: if agent_path is None: print(f"error: `{_agent_binary(spec)}` is no longer on PATH (memU#538 Symptom A)", file=sys.stderr) return 1 - if _resolve_scheduled_agent(agent_path) is None: - print( - f"error: `{_agent_binary(spec)}` resolves to a .cmd/.bat launcher without its sibling .ps1 shim", - file=sys.stderr, - ) - return 1 layout.base.mkdir(parents=True, exist_ok=True) if (rc := _auth_gate(spec, agent_path, layout.base)) != 0: return rc diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index 176a7dc4..af3ff668 100644 --- a/tests/test_scheduling_windows.py +++ b/tests/test_scheduling_windows.py @@ -85,7 +85,9 @@ def test_wrapper_keeps_prompt_off_the_command_line(tmp_path: Path) -> None: assert "$env:Path = 'C:\\bin;C:\\memu;' + $env:Path" in text -def test_install_uses_powershell_companion_for_npm_cmd_shim(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_install_invokes_path_resolvable_command_for_npm_cmd_shim( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() cmd_shim = bin_dir / "pi.CMD" @@ -107,20 +109,10 @@ def test_install_uses_powershell_companion_for_npm_cmd_shim(monkeypatch: pytest. assert windows.install(PI, layout) == 0 wrapper = (layout.base / windows.WRAPPER_NAME).read_text(encoding="utf-8-sig") - assert f"& '{ps_shim}' -p $prompt" in wrapper + assert "& 'pi' -p $prompt" in wrapper + assert f"$env:Path = '{bin_dir};' + $env:Path" in wrapper assert str(cmd_shim) not in wrapper - - -def test_install_rejects_batch_launcher_without_powershell_companion( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - cmd_shim = tmp_path / "pi.cmd" - cmd_shim.write_text("@ECHO off\r\n", encoding="utf-8") - monkeypatch.setattr(windows.platform, "system", lambda: "Windows") - monkeypatch.setattr(windows, "_resolve_agent", lambda spec: str(cmd_shim)) - - assert windows.install(PI, Layout.default(host=PI.host, base=tmp_path / "host")) == 1 - assert "no sibling PowerShell shim" in capsys.readouterr().err + assert str(ps_shim) not in wrapper def test_register_script_is_canonical_and_hardened() -> None: From 01bf1206a9a490f08177078b9f3027d67b151599 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:53:11 +0800 Subject: [PATCH 05/10] Update src/memu/hosts/pi/cli.py Co-authored-by: Korewaxnne <2557248400@qq.com> --- src/memu/hosts/pi/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/memu/hosts/pi/cli.py b/src/memu/hosts/pi/cli.py index fc052cd0..1c2e343f 100644 --- a/src/memu/hosts/pi/cli.py +++ b/src/memu/hosts/pi/cli.py @@ -26,7 +26,6 @@ schedule_prepare_session_dir=True, session_id_env="PI_SESSION_ID", needs_headless_auth=True, - install_hint=" npm install -g --ignore-scripts @earendil-works/pi-coding-agent", auth_hint=" authenticate pi with /login or persist the API key used by its selected provider", ) From 8a0c4d1e1c31a72909d370f971150889a2b362c0 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:48:28 +0800 Subject: [PATCH 06/10] fix(pi): match exported session identity --- src/memu/hosts/pi/sessions.py | 4 +++ tests/test_bridging_self_sessions.py | 41 ++++++++++++++++++++++++++++ tests/test_host_sessions.py | 7 +++++ 3 files changed, 52 insertions(+) diff --git a/src/memu/hosts/pi/sessions.py b/src/memu/hosts/pi/sessions.py index a334ebe0..54cccecf 100644 --- a/src/memu/hosts/pi/sessions.py +++ b/src/memu/hosts/pi/sessions.py @@ -24,6 +24,10 @@ def __init__(self, session_dir: str | Path = SESSION_DIR) -> None: def root(self) -> Path: return self._root + def session_id(self, path: Path) -> str: + """Return the UUID Pi exports, without its filename timestamp prefix.""" + return path.stem.rsplit("_", 1)[-1] + def classify(self, record: str) -> RecordKind: try: entry = json.loads(record) diff --git a/tests/test_bridging_self_sessions.py b/tests/test_bridging_self_sessions.py index e82369a1..4eaebdd7 100644 --- a/tests/test_bridging_self_sessions.py +++ b/tests/test_bridging_self_sessions.py @@ -26,6 +26,7 @@ from memu.hosts.hermes.cli import SESSION_ID_ENV as HERMES_SESSION_ID_ENV from memu.hosts.hermes.cli import SPEC as HERMES_SPEC from memu.hosts.hermes.sessions import HermesTranscriptSource +from memu.hosts.pi.cli import SPEC as PI_SPEC class FakeSource(TranscriptSource): @@ -163,6 +164,46 @@ def test_skipping_frees_the_job_slots_for_real_sessions(tmp_path: pathlib.Path) assert set(staged) == {"real-2.jsonl", "real-1.jsonl"} +async def test_pi_current_and_previous_bridge_sessions_produce_no_jobs( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + previous_id = "01a061ac-aaf3-7f05-a295-d95115fef654" + current_id = "01a061ac-aaf3-7f05-a295-d95115fef655" + session_dir = tmp_path / "pi-sessions" / "--workspace--" + session_dir.mkdir(parents=True) + _session(session_dir, f"2026-09-02T09-00-00-000Z_{previous_id}", turns=2, mtime=1000) + _session(session_dir, f"2026-09-02T10-31-41-043Z_{current_id}", turns=2, mtime=2000) + + base = tmp_path / "memu-pi" + layout = Layout.default(host=PI_SPEC.host, base=base) + self_sessions.remember(layout.self_sessions, previous_id) + + class EmptyRecallService: + async def list_all_recall_files(self, *args: object, **kwargs: object) -> dict[str, object]: + return {"recall_files": [], "next_cursor": None} + + from memu.hosts.bridging import pipeline + + monkeypatch.setattr(pipeline, "build_agentic_memory_backend_from_env", lambda: EmptyRecallService()) + monkeypatch.setattr(pipeline.templates, "resolve", lambda _name, fallback: fallback) + monkeypatch.setattr(pipeline.events, "record_list", lambda **kwargs: None) + monkeypatch.setattr(host_cli, "_mark_cycle_start", lambda spec, layout: None) + monkeypatch.setattr(host_cli, "_refresh_retrieval", lambda spec: None) + monkeypatch.setattr(host_cli.events, "flush", lambda: None) + monkeypatch.setenv(self_sessions.BRIDGING_RUN_ENV, "1") + monkeypatch.setenv(PI_SPEC.session_id_env, current_id) + + rc = await host_cli._cmd_prepare( + PI_SPEC, + Namespace(session_dir=str(session_dir.parent), base_dir=str(base), max_jobs=10), + ) + + assert rc == 0 + assert self_sessions.load(layout.self_sessions) == [previous_id, current_id] + assert list(layout.sessions.glob("*.jsonl")) == [] + assert list(layout.jobs.glob("*.txt")) == [] + + # ── only the scheduled run may claim a session ───────────────────────────────── diff --git a/tests/test_host_sessions.py b/tests/test_host_sessions.py index ab79a283..a75db620 100644 --- a/tests/test_host_sessions.py +++ b/tests/test_host_sessions.py @@ -84,6 +84,13 @@ def test_pi_discovers_sessions_across_encoded_working_directories(tmp_path: path assert PiTranscriptSource(tmp_path).discover() == [newer, older] +def test_pi_session_id_matches_the_exported_uuid(tmp_path: pathlib.Path) -> None: + source = PiTranscriptSource(tmp_path) + transcript = tmp_path / "2026-09-02T10-31-41-043Z_01a061ac-aaf3-7f05-a295-d95115fef655.jsonl" + + assert source.session_id(transcript) == "01a061ac-aaf3-7f05-a295-d95115fef655" + + # ── Cola ────────────────────────────────────────────────────────────────────── From efc80cf30abe609e13a28c915d4b53169558f0c5 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:21:15 +0800 Subject: [PATCH 07/10] fix(pi): sanitize prepared transcripts --- src/memu/hosts/pi/sessions.py | 52 ++++++++++++++++ tests/test_host_sessions.py | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/memu/hosts/pi/sessions.py b/src/memu/hosts/pi/sessions.py index 54cccecf..6f862649 100644 --- a/src/memu/hosts/pi/sessions.py +++ b/src/memu/hosts/pi/sessions.py @@ -12,6 +12,31 @@ AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent") SESSION_DIR = os.environ.get("PI_CODING_AGENT_SESSION_DIR", f"{AGENT_DIR}/sessions") +# Delete-only: unknown record, message, and content-block fields are preserved. +_RECORD_PRIVATE_FIELDS = frozenset({"id", "parentId", "timestamp"}) +_MESSAGE_PRIVATE_FIELDS = frozenset({ + "api", + "errorMessage", + "model", + "provider", + "rawStopReason", + "responseId", + "stopReason", + "timestamp", + "usage", +}) +_TOOL_RESULT_PRIVATE_FIELDS = frozenset({"details"}) +_BLOCK_PRIVATE_FIELDS = frozenset({"thinkingSignature"}) + + +def _drop_known_fields(value: dict[object, object], fields: frozenset[str]) -> bool: + changed = False + for field in fields: + if field in value: + del value[field] + changed = True + return changed + class PiTranscriptSource(TranscriptSource): """Split pi's parent-linked message entries into conversation and tool tracks.""" @@ -28,6 +53,33 @@ def session_id(self, path: Path) -> str: """Return the UUID Pi exports, without its filename timestamp prefix.""" return path.stem.rsplit("_", 1)[-1] + def sanitize(self, path: Path, record: str) -> str: + """Remove known Pi runtime metadata from prepared transcript output.""" + del path # Required by the host seam; every Pi session has the same record shape. + try: + entry = json.loads(record) + except json.JSONDecodeError: + return record + if not isinstance(entry, dict): + return record + + changed = _drop_known_fields(entry, _RECORD_PRIVATE_FIELDS) + message = entry.get("message") + if not isinstance(message, dict): + return json.dumps(entry, ensure_ascii=False) if changed else record + + changed |= _drop_known_fields(message, _MESSAGE_PRIVATE_FIELDS) + if message.get("role") == "toolResult": + changed |= _drop_known_fields(message, _TOOL_RESULT_PRIVATE_FIELDS) + + content = message.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + changed |= _drop_known_fields(block, _BLOCK_PRIVATE_FIELDS) + + return json.dumps(entry, ensure_ascii=False) if changed else record + def classify(self, record: str) -> RecordKind: try: entry = json.loads(record) diff --git a/tests/test_host_sessions.py b/tests/test_host_sessions.py index a75db620..59cb7a10 100644 --- a/tests/test_host_sessions.py +++ b/tests/test_host_sessions.py @@ -91,6 +91,115 @@ def test_pi_session_id_matches_the_exported_uuid(tmp_path: pathlib.Path) -> None assert source.session_id(transcript) == "01a061ac-aaf3-7f05-a295-d95115fef655" +def test_pi_sanitize_removes_runtime_fields_and_preserves_content(tmp_path: pathlib.Path) -> None: + source = PiTranscriptSource(tmp_path) + assistant = { + "id": "record-id", + "parentId": "parent-id", + "timestamp": "2026-09-02T12:00:00Z", + "type": "message", + "futureRecordField": "preserved", + "message": { + "role": "assistant", + "api": "private-api", + "provider": "private-provider", + "model": "private-model", + "usage": {"input": 1}, + "stopReason": "error", + "rawStopReason": "private-reason", + "responseId": "private-response", + "timestamp": 1788350400000, + "errorMessage": "private-error", + "futureMessageField": "preserved", + "content": [ + { + "type": "thinking", + "thinking": "reasoning", + "thinkingSignature": "private-signature", + "futureBlockField": "preserved", + }, + {"type": "text", "text": "answer"}, + ], + }, + } + tool_result = { + "id": "result-id", + "parentId": "record-id", + "timestamp": "2026-09-02T12:00:01Z", + "type": "message", + "message": { + "role": "toolResult", + "timestamp": 1788350401000, + "toolCallId": "call-id", + "toolName": "read", + "details": {"private": True}, + "isError": False, + "content": [{"type": "text", "text": "result"}], + }, + } + + assert json.loads(source.sanitize(tmp_path / "session.jsonl", _line(assistant))) == { + "type": "message", + "futureRecordField": "preserved", + "message": { + "role": "assistant", + "futureMessageField": "preserved", + "content": [ + {"type": "thinking", "thinking": "reasoning", "futureBlockField": "preserved"}, + {"type": "text", "text": "answer"}, + ], + }, + } + assert json.loads(source.sanitize(tmp_path / "session.jsonl", _line(tool_result))) == { + "type": "message", + "message": { + "role": "toolResult", + "toolCallId": "call-id", + "toolName": "read", + "isError": False, + "content": [{"type": "text", "text": "result"}], + }, + } + + +def test_pi_prepare_sanitizes_output_without_changing_source_or_cursor(tmp_path: pathlib.Path) -> None: + root = tmp_path / "sessions" + session = root / "--workspace--" / "2026-09-02T12-00-00-000Z_session-id.jsonl" + session.parent.mkdir(parents=True) + record = { + "id": "record-id", + "parentId": "parent-id", + "timestamp": "2026-09-02T12:00:00Z", + "type": "message", + "message": { + "role": "assistant", + "provider": "private-provider", + "usage": {"input": 1}, + "content": [{"type": "text", "text": "answer"}], + }, + } + raw = _line(record) + "\n" + session.write_text(raw, encoding="utf-8") + + out = tmp_path / "prepared" + pending = tmp_path / "manifest.json.pending" + assert prepare_transcripts(PiTranscriptSource(root), out, tmp_path / "manifest.json", 10, pending) == 1 + + expected = { + "type": "message", + "message": {"role": "assistant", "content": [{"type": "text", "text": "answer"}]}, + } + assert json.loads((out / "1.jsonl").read_text(encoding="utf-8")) == expected + assert json.loads((out / "1_full.jsonl").read_text(encoding="utf-8")) == expected + assert session.read_text(encoding="utf-8") == raw + assert json.loads(pending.read_text(encoding="utf-8")) == { + "--workspace--/2026-09-02T12-00-00-000Z_session-id.jsonl": { + "lines": 1, + "last_timestamp": "2026-09-02T12:00:00Z", + } + } + + # ── Cola ────────────────────────────────────────────────────────────────────── From 8376fb0432c58a1a0baf9b2dcea62e7dd30e79fd Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:17:21 +0800 Subject: [PATCH 08/10] fix(pi): drop scheduler auth gate and custom session baking Pi's interactive CLI and scheduled pi -p share ~/.pi/agent/auth.json, so memU must not probe /login or an API key at schedule install/verify. That gate belongs to Claude Code, whose desktop login is invisible to standalone claude. Scheduled prepare stays on the default ~/.pi/agent/sessions layout; baking an install-time --session-dir would freeze a path the OS task cannot see. --- src/memu/hosts/pi/BRIDGING_TASK.md | 13 +++++-------- src/memu/hosts/pi/INSTALL.md | 14 +++++--------- src/memu/hosts/pi/cli.py | 3 --- tests/test_scheduling_windows.py | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/memu/hosts/pi/BRIDGING_TASK.md b/src/memu/hosts/pi/BRIDGING_TASK.md index 8f7fa922..98a152df 100644 --- a/src/memu/hosts/pi/BRIDGING_TASK.md +++ b/src/memu/hosts/pi/BRIDGING_TASK.md @@ -23,9 +23,6 @@ Write the following line verbatim to Run the memU bridging pipeline. Do the four steps strictly in order; do not skip a step even if the previous one looks like it produced nothing. 1. LEFTOVERS. If ~/.memu/hosts/pi/jobs/ already contains job files, they are unfinished work from an earlier run (a crash, or the install itself) — process them exactly as step 3 describes, then run: memu-pi commit — and only then continue. 2. PREPARE. Run this exact command with bash: memu-pi prepare — it regenerates ~/.memu/hosts/pi/jobs/. If the command exits non-zero, stop and report the error. 3. SELF-EVOLVE. List ~/.memu/hosts/pi/jobs/*.txt and process them in ascending numeric order (1.txt, then 2.txt, …). The count changes every run — always glob and sort. If there are no job files, skip to step 4. For each job file: read it and follow its instructions to the letter. Each job is self-contained and already carries the concrete paths it needs. Emitting no files for a job is a valid outcome; do not invent content. 4. COMMIT. Run this exact command with bash: memu-pi commit — it commits whatever the jobs created or changed. If it exits non-zero, report the error. ON FAILURE. If step 2 or step 4 exited non-zero, run this once before you stop: memu-pi report error --stage remember --detail "" — that detail is all a memU engineer gets to work out what is broken on this machine, so be generous: which step, what you ran, what happened instead, what you already tried, and what you think the cause is. Write it as prose for a human, not as a transcript — do not paste the traceback or raw command output, which the CLI already reports on its own, and keep credentials, absolute paths, and memory or transcript text out of it. Ignore any failure of that command; it is never part of the run. Finish with a one-line summary: how many jobs ran (leftovers included) and what was committed. ``` -If pi uses a custom session directory, replace only `memu-pi prepare` in that -file with `memu-pi prepare --session-dir `. - Write `~/.memu/hosts/pi/bridge.sh` and make it executable: ```sh @@ -70,14 +67,14 @@ memu-pi schedule verify memu-pi schedule status ``` -The helper resolves `pi`, runs a headless authentication probe, and registers -`{{task_name}}`. `--interval ` changes the default 60-minute cadence. -`schedule verify` checks registration and authentication only; it does not run -the S4U task and is not end-to-end proof. +The helper resolves `pi` and registers `{{task_name}}`. `--interval ` +changes the default 60-minute cadence. `schedule verify` checks registration +only; it does not run the S4U task and is not end-to-end proof. ## Verify -Trigger one run and inspect filesystem evidence: `bridge.log` grows and the +Confirm the task is registered, then trigger one real OS run. Inspect +filesystem evidence: a new pi session appears, `bridge.log` grows, and the session manifest or job timestamps advance. Do not rely only on the agent's summary. The scheduled pi tool process exports `PI_SESSION_ID`, so `prepare` records that run as bridge-owned and does not mine it later. diff --git a/src/memu/hosts/pi/INSTALL.md b/src/memu/hosts/pi/INSTALL.md index e03fa468..b13a3bcf 100644 --- a/src/memu/hosts/pi/INSTALL.md +++ b/src/memu/hosts/pi/INSTALL.md @@ -53,13 +53,10 @@ The command must exit cleanly. Zero retrieval hits is normal for a new store. ## Part 2 — Register the record bridge -By default pi stores v3 JSONL sessions below `~/.pi/agent/sessions`. The adapter -keeps user and assistant text, routes tool calls and results to the full -transcript, and ignores session, compaction, model, and thinking metadata. - -If `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, or `settings.json` moves -the session directory, pass the final directory to `memu-pi prepare ---session-dir ` and use the same explicit path in the scheduled prompt. +Pi stores v3 JSONL sessions below `~/.pi/agent/sessions`. The adapter keeps +user and assistant text, routes tool calls and results to the full transcript, +and ignores session, compaction, model, and thinking metadata. The scheduled +bridge only supports this default layout. **Refresh an existing bridging registration before continuing.** Check cron or launchd for `hosts/pi/bridge\.sh|memU bridging pipeline`, or Task Scheduler for @@ -85,8 +82,7 @@ It must report a session count. Zero is correct when no new turns exist. Pi loads one global context file from its agent directory. Normally that is `~/.pi/agent/AGENTS.md`; if `AGENTS.override.md` already exists there, it takes -precedence, so target that file instead. A custom `PI_CODING_AGENT_DIR` moves -both the instruction and skills directories. +precedence, so target that file instead. Default installation: diff --git a/src/memu/hosts/pi/cli.py b/src/memu/hosts/pi/cli.py index 1c2e343f..38fe7585 100644 --- a/src/memu/hosts/pi/cli.py +++ b/src/memu/hosts/pi/cli.py @@ -23,10 +23,7 @@ skills_dir=SKILLS_DIR, schedule_backend="os", schedule_command="pi -p {prompt}", - schedule_prepare_session_dir=True, session_id_env="PI_SESSION_ID", - needs_headless_auth=True, - auth_hint=" authenticate pi with /login or persist the API key used by its selected provider", ) diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index af3ff668..5e31670e 100644 --- a/tests/test_scheduling_windows.py +++ b/tests/test_scheduling_windows.py @@ -113,6 +113,9 @@ def test_install_invokes_path_resolvable_command_for_npm_cmd_shim( assert f"$env:Path = '{bin_dir};' + $env:Path" in wrapper assert str(cmd_shim) not in wrapper assert str(ps_shim) not in wrapper + scheduled = (layout.base / windows.PROMPT_NAME).read_text(encoding="utf-8") + assert "memu-pi prepare" in scheduled + assert "--session-dir" not in scheduled def test_register_script_is_canonical_and_hardened() -> None: @@ -335,6 +338,20 @@ def test_hermes_scheduled_prompt_bakes_in_its_session_store() -> None: assert "--session-dir" not in prompt.bridging_pipeline_prompt(CLAUDE) +def test_pi_scheduler_does_not_copy_claude_auth_or_hermes_session_bake() -> None: + # Interactive `pi` and scheduled `pi -p` are the same CLI. Credentials live + # in ~/.pi/agent/auth.json (or a persistent env), so there is no separate + # headless login for memU to probe — unlike Claude Code, whose desktop login + # is invisible to standalone `claude`. Sessions live at the default + # ~/.pi/agent/sessions; unlike Hermes, that path does not move with a runtime + # home the S4U task would fail to inherit, so scheduled prepare must not bake + # an install-time --session-dir. + assert PI.needs_headless_auth is False + assert PI.auth_hint == "" + assert PI.schedule_prepare_session_dir is False + assert "--session-dir" not in prompt.bridging_pipeline_prompt(PI) + + def test_cursor_template_carries_the_trust_flag_everywhere() -> None: # cursor-agent refuses headless runs in an untrusted directory (field-verified: # "Workspace Trust Required", exit 1). Because the flag lives in the template, From 5576bad87187e655cd9fd462b1b525dae0d01d63 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:58:28 +0800 Subject: [PATCH 09/10] fix(pi): pin session source to the default layout AGENT_DIR and SESSION_DIR no longer follow PI_CODING_AGENT_DIR or PI_CODING_AGENT_SESSION_DIR. Manual prepare and the later cron/S4U prepare must share ~/.pi/agent/sessions; reading process env would make the install-time cursor track a store the scheduled task does not inherit. Custom Pi directories stay out of this adapter; a later PR would need an explicit source migration. --- src/memu/hosts/pi/sessions.py | 7 +++---- tests/test_host_sessions.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/memu/hosts/pi/sessions.py b/src/memu/hosts/pi/sessions.py index 6f862649..5e49730e 100644 --- a/src/memu/hosts/pi/sessions.py +++ b/src/memu/hosts/pi/sessions.py @@ -3,14 +3,13 @@ from __future__ import annotations import json -import os from pathlib import Path from typing import ClassVar from memu.hosts.base import RecordKind, TranscriptSource -AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent") -SESSION_DIR = os.environ.get("PI_CODING_AGENT_SESSION_DIR", f"{AGENT_DIR}/sessions") +AGENT_DIR = "~/.pi/agent" +SESSION_DIR = "~/.pi/agent/sessions" # Delete-only: unknown record, message, and content-block fields are preserved. _RECORD_PRIVATE_FIELDS = frozenset({"id", "parentId", "timestamp"}) @@ -44,7 +43,7 @@ class PiTranscriptSource(TranscriptSource): name: ClassVar[str] = "pi" def __init__(self, session_dir: str | Path = SESSION_DIR) -> None: - self._root = Path(os.path.expanduser(str(session_dir))) + self._root = Path(session_dir).expanduser() def root(self) -> Path: return self._root diff --git a/tests/test_host_sessions.py b/tests/test_host_sessions.py index 59cb7a10..2f58e63e 100644 --- a/tests/test_host_sessions.py +++ b/tests/test_host_sessions.py @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib import json import os import pathlib @@ -200,6 +201,22 @@ def test_pi_prepare_sanitizes_output_without_changing_source_or_cursor(tmp_path: } +def test_pi_session_dirs_ignore_process_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + # Manual prepare and the later cron/S4U prepare must share one store. Reading + # PI_CODING_AGENT_DIR / PI_CODING_AGENT_SESSION_DIR would make the install-time + # process see a custom directory the scheduled task does not inherit. + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path / "custom-pi")) + monkeypatch.setenv("PI_CODING_AGENT_SESSION_DIR", str(tmp_path / "custom-sessions")) + from memu.hosts.pi import sessions as pi_sessions + + importlib.reload(pi_sessions) + assert pi_sessions.AGENT_DIR == "~/.pi/agent" + assert pi_sessions.SESSION_DIR == "~/.pi/agent/sessions" + assert pi_sessions.PiTranscriptSource().root() == pathlib.Path("~/.pi/agent/sessions").expanduser() + + # ── Cola ────────────────────────────────────────────────────────────────────── From c78d335e2e215b1516387dfe2c702f141e9b1d31 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:59:50 +0800 Subject: [PATCH 10/10] style(pi): wrap session-dir test to satisfy ruff format --- tests/test_host_sessions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_host_sessions.py b/tests/test_host_sessions.py index 2f58e63e..4082644c 100644 --- a/tests/test_host_sessions.py +++ b/tests/test_host_sessions.py @@ -201,9 +201,7 @@ def test_pi_prepare_sanitizes_output_without_changing_source_or_cursor(tmp_path: } -def test_pi_session_dirs_ignore_process_environment( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path -) -> None: +def test_pi_session_dirs_ignore_process_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: # Manual prepare and the later cron/S4U prepare must share one store. Reading # PI_CODING_AGENT_DIR / PI_CODING_AGENT_SESSION_DIR would make the install-time # process see a custom directory the scheduled task does not inherit.