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..98a152df --- /dev/null +++ b/src/memu/hosts/pi/BRIDGING_TASK.md @@ -0,0 +1,80 @@ +--- +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. +``` + +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` 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 + +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 new file mode 100644 index 00000000..b13a3bcf --- /dev/null +++ b/src/memu/hosts/pi/INSTALL.md @@ -0,0 +1,132 @@ +# 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`. + +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 +``` + +The command must exit cleanly. Zero retrieval hits is normal for a new store. + +## Part 2 — Register the record bridge + +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 +`{{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. + +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..38fe7585 --- /dev/null +++ b/src/memu/hosts/pi/cli.py @@ -0,0 +1,35 @@ +"""``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}", + session_id_env="PI_SESSION_ID", +) + + +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..5e49730e --- /dev/null +++ b/src/memu/hosts/pi/sessions.py @@ -0,0 +1,109 @@ +"""pi v3 session transcripts: ``~/.pi/agent/sessions//*.jsonl``.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import ClassVar + +from memu.hosts.base import RecordKind, TranscriptSource + +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"}) +_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.""" + + name: ClassVar[str] = "pi" + + def __init__(self, session_dir: str | Path = SESSION_DIR) -> None: + self._root = Path(session_dir).expanduser() + + 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 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) + 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/src/memu/hosts/scheduling/windows.py b/src/memu/hosts/scheduling/windows.py index a0e68133..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([ @@ -294,6 +294,7 @@ def install(spec: HostSpec, layout: Layout, *, interval_minutes: int = DEFAULT_I 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 @@ -321,7 +322,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 +340,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 +394,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 @@ -431,7 +433,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_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_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..4082644c 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. @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib import json import os import pathlib @@ -23,6 +24,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 +32,189 @@ 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] + + +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" + + +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", + } + } + + +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 ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index ea22c383..5e31670e 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", ()), } @@ -83,6 +85,39 @@ 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_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" + 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 "& 'pi' -p $prompt" in wrapper + 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: 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 @@ -220,6 +255,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") @@ -284,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, @@ -444,7 +512,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 +562,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 +649,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(