diff --git a/.opencode/scripts/setup-memory.sh b/.opencode/scripts/setup-memory.sh index 71176d7..beb743c 100755 --- a/.opencode/scripts/setup-memory.sh +++ b/.opencode/scripts/setup-memory.sh @@ -4,6 +4,17 @@ set -euo pipefail MEMORY_DIR="${OPENCODE_MEMORY_DIR:-/root/.local/share/opencode/opencode-memory}" HOOK="$MEMORY_DIR/.git/hooks/post-commit" BRANCH="master" +WRAPPER_PATH="${MEMORY_WRAPPER_PATH:-/usr/local/lib/node_modules/@mathew-cf/opencode-memory/node_modules/@mathew-cf/rag-cli/bin/rag.js}" + +# Pick a Python that can import src.memory: prefer workspace .venv, then CWD +# .venv, then system python3. Override via MEMORY_WRAPPER_PYTHON for tests. +if [ -n "${OPENCODE_WORKSPACE:-}" ] && [ -x "${OPENCODE_WORKSPACE}/.venv/bin/python" ]; then + MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-${OPENCODE_WORKSPACE}/.venv/bin/python}" +elif [ -x ".venv/bin/python" ]; then + MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-.venv/bin/python}" +else + MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-python3}" +fi EXPECTED_HOOK="#!/bin/bash git push origin ${BRANCH} 2>/dev/null || true" @@ -64,20 +75,51 @@ else echo " [4/6] hook correct" fi -# 5. .rag index exists? → rag index (no) | noop (yes) -if command -v rag >/dev/null 2>&1; then +# 5. .rag index exists? → memory index (no) | noop (yes) +if "$MEMORY_PYTHON" -c "import src.memory" >/dev/null 2>&1; then if [ ! -d "$MEMORY_DIR/.rag" ]; then - echo " [5/6] building RAG index" - (cd "$MEMORY_DIR" && rag index) 2>/dev/null || echo " rag index failed — continuing" + echo " [5/7] building RAG index (memory CLI)" + ( "$MEMORY_PYTHON" -m src.memory index "$MEMORY_DIR" -o "$MEMORY_DIR/.rag" ) 2>&1 \ + || echo " memory index failed — continuing" else - echo " [5/6] RAG index exists" + echo " [5/7] RAG index exists" fi else - echo " [5/6] rag CLI not installed — skipping index" + echo " [5/7] memory CLI not available — skipping index" +fi + +# 5b. JS wrapper for opencode-memory plugin — delegates rag CLI calls to +# Python memory CLI. Idempotent (content check via cmp), backs up +# original once. +WRAPPER_DIR="$(dirname "$WRAPPER_PATH")" +mkdir -p "$WRAPPER_DIR" +WRAPPER_CONTENT='#!/usr/bin/env node +// Generated by setup-memory.sh — delegates rag CLI calls to Python memory CLI +const { spawnSync } = require("child_process"); +const py = process.env.MEMORY_WRAPPER_PYTHON || "'"$MEMORY_PYTHON"'"; +const r = spawnSync(py, ["-m", "src.memory", ...process.argv.slice(2)], { + stdio: "inherit", + cwd: process.env.OPENCODE_WORKSPACE || process.cwd() +}); +process.exit(r.status || 0); +' + +if [ -f "$WRAPPER_PATH" ] && cmp -s "$WRAPPER_PATH" /dev/stdin <<<"$WRAPPER_CONTENT"; then + echo " [5b/7] wrapper correct" +else + if [ -f "$WRAPPER_PATH" ]; then + if [ ! -f "${WRAPPER_PATH}.orig" ]; then + cp "$WRAPPER_PATH" "${WRAPPER_PATH}.orig" + echo " [5b/7] backed up original wrapper → ${WRAPPER_PATH}.orig" + fi + fi + printf '%s\n' "$WRAPPER_CONTENT" > "$WRAPPER_PATH" + chmod +x "$WRAPPER_PATH" + echo " [5b/7] generated wrapper (delegates → python memory CLI)" fi # 6. status -echo " [6/6] done" +echo " [7/7] done" echo "" echo "memory: ready at $MEMORY_DIR" echo " remote: $REMOTE" diff --git a/docs/decisions/033-pr-77-wire-plugin-via-setup-memory.md b/docs/decisions/033-pr-77-wire-plugin-via-setup-memory.md new file mode 100644 index 0000000..27db677 --- /dev/null +++ b/docs/decisions/033-pr-77-wire-plugin-via-setup-memory.md @@ -0,0 +1,27 @@ +# ADR-033: Wire memory CLI into opencode-memory plugin via setup-memory.sh wrapper (PR #77) + +## Статус +Accepted (2026-07-26) + +## Контекст +PR #75 (merged) отрефакторил Python Memory CLI (`src/memory/`): переименовал `second-brain` → `memory`, перешёл на OpenAI env (`OPENAI_BASE_URL`/`OPENAI_API_KEY`/`OPENAI_EMBEDDING_MODEL`), добавил chunking/batching/dedup. CLI работает локально (`python -m src.memory index/search/download`). + +Но плагин `@mathew-cf/opencode-memory` (MCP server, загружается opencode) всё ещё вызывал родной Rust `@mathew-cf/rag-cli` через `require.resolve("@mathew-cf/rag-cli/bin/rag.js")` (вендорный `dist/index.js:12703-12708`, НЕ патчим — upstream код). Rust rag-cli: 5-10 мин на 1.2 MB на CPU, 3 зомби-процесса зафиксировано, индекс никогда не записан (`$OPENCODE_MEMORY_DIR/.rag` отсутствовал). Memory plugin semantic search был нерабочим — active только keyword (ripgrep) path. + +Контракт плагина (4 подкоманды, детерминированный): `rag index -o `, `rag search "" -i -k 15 --json`, `rag download`, `rag info`. Наш Python CLI уже совпадает по контракту (PR #75). Нужен bridge: перехватить `require.resolve` плагина → перенаправить в Python CLI. + +## Решение +1. **setup-memory.sh шаг 5 → Python memory CLI.** Замена `command -v rag` → `"$MEMORY_PYTHON" -c "import src.memory"`. Если доступен → `python -m src.memory index "$MEMORY_DIR" -o "$MEMORY_DIR/.rag"`. Если нет → echo skip + continue (best-effort, не exit 1). +2. **`MEMORY_PYTHON` выбор интерпретатора.** System `python3` не имеет `httpx` (нет venv) — CLI падает на import. Скрипт выбирает: `${OPENCODE_WORKSPACE}/.venv/bin/python` → `.venv/bin/python` → `python3`. Override через `MEMORY_WRAPPER_PYTHON` env (для тестов). +3. **Шаг 5b — генерация JS wrapper.** Путь: `WRAPPER_PATH` env (default `/usr/local/lib/node_modules/@mathew-cf/opencode-memory/node_modules/@mathew-cf/rag-cli/bin/rag.js`, override через `MEMORY_WRAPPER_PATH`). Содержимое: `spawnSync(py, ["-m", "src.memory", ...process.argv.slice(2)], { cwd: OPENCODE_WORKSPACE })` — делегирует `rag index/search/download` в Python CLI. `py` = `process.env.MEMORY_WRAPPER_PYTHON` (override) или встроенный абсолютный путь к venv-python (default). +4. **Idempotency через `cmp -s`.** Сравнение `cmp -s "$WRAPPER_PATH" /dev/stdin <<<"$WRAPPER_CONTENT"` — байт-точное. Bash `$(cat)` удаляет trailing newlines → false-negative (см. handoff Watch out). Если контент совпадает → echo "wrapper correct", не трогает. +5. **Backup `.orig` один раз.** Если wrapper существует и контент отличается → `cp "$WRAPPER_PATH" "${WRAPPER_PATH}.orig"` (только если `.orig` ещё не существует — не перезаписывать backup). Записывает новый wrapper только если контент отличается. +6. **`MEMORY_WRAPPER_PATH` env override.** Тесты используют `tmp_path/wrapper/rag.js` для изоляции (не пишут в real plugin path). Default = real path для smoke-test. + +## Альтернативы +- **`spawnSync("python3", ["-m", "src.memory", ...])` как в спеке** — отклонено: system `python3` не имеет `httpx` (нет venv), CLI падает на `ModuleNotFoundError: No module named 'httpx'`. Нужен venv-python. Реализация выбирает venv автоматически + встраивает путь в wrapper. Runtime override через `MEMORY_WRAPPER_PYTHON` env оставлен как escape hatch. +- **Wrapper читает Python путь из env в runtime** (`process.env.MEMORY_WRAPPER_PYTHON || "python3"`) — отклонено как default: env может быть не set в контексте плагина (MCP server запускается opencode, env наследуется непредсказуемо). Встраивание абсолютного пути к venv-python надёжнее. Override через env оставлен для тестов/кастомных setups. +- **Патчить вендорный `dist/index.js` плагина** — отклонено: upstream код, update плагина затрёт патч. Wrapper по `require.resolve` пути — прозрачный bridge, survives plugin updates (если plugin не меняет `require.resolve` логику). +- **Форкнуть плагин** — отклонено: maintenance burden, sync с upstream. Wrapper = минимальный shim, не требует fork. +- **`$(cat)` для idempotency check** — отклонено: bash `$()` удаляет trailing newlines → false-negative при сравнении с `$VAR` содержащим `\n`. `cmp -s` с here-string — байт-точное, no surprises. +- **Отдельный subagent для wrapper generation** — отклонено: setup-memory.sh = deterministic bootstrap script, wrapper gen = ещё один idempotent step. Subagent = orchestrator overhead для trivial операции. \ No newline at end of file diff --git a/docs/handoff/pr-77-wire-plugin-via-setup-memory.md b/docs/handoff/pr-77-wire-plugin-via-setup-memory.md new file mode 100644 index 0000000..5935f05 --- /dev/null +++ b/docs/handoff/pr-77-wire-plugin-via-setup-memory.md @@ -0,0 +1,30 @@ +--- +pr: 77 +title: Wire memory CLI into opencode-memory plugin via setup-memory.sh wrapper +--- + +## Что сделано +- `.opencode/scripts/setup-memory.sh`: добавлена `WRAPPER_PATH` env-переменная (default `/usr/local/lib/node_modules/@mathew-cf/opencode-memory/node_modules/@mathew-cf/rag-cli/bin/rag.js`, override через `MEMORY_WRAPPER_PATH`). +- `.opencode/scripts/setup-memory.sh`: добавлен выбор Python-интерпретатора `MEMORY_PYTHON` — prefer `${OPENCODE_WORKSPACE}/.venv/bin/python`, then `.venv/bin/python`, then `python3` (override через `MEMORY_WRAPPER_PYTHON`). Причина: system `python3` не имеет `httpx` (нет venv), CLI падает на import — wrapper должен вызывать venv-python. +- `.opencode/scripts/setup-memory.sh` шаг 5: `command -v rag` → `"$MEMORY_PYTHON" -c "import src.memory"`. Если доступен → `python -m src.memory index "$MEMORY_DIR" -o "$MEMORY_DIR/.rag"`. Если нет → echo skip + continue (best-effort, не exit 1). +- `.opencode/scripts/setup-memory.sh` шаг 5b (новый): генерация JS wrapper для плагина `@mathew-cf/opencode-memory`. Wrapper содержит `spawnSync(py, ["-m", "src.memory", ...process.argv.slice(2)], { cwd: OPENCODE_WORKSPACE })` — делегирует `rag index/search/download` в Python memory CLI. Idempotent через `cmp -s` (сравнение контента, НЕ `$(cat)` — bash `$()` удаляет trailing newline, давал false-negative). Backup `.orig` оригинала один раз (не перезаписывается). `chmod +x`. +- `tests/test_setup_memory.py`: `_run_script` расширен `wrapper: Path | None` параметром → `MEMORY_WRAPPER_PATH` env override. Все существующие тесты обновлены передавать `wrapper=tmp_path/"wrapper"/"rag.js"` (герметичность — не пишут в `/usr/local/lib/...`). insteadOf-тесты (прямые `subprocess.run`) тоже обновлены. mypy type-args починены (`CompletedProcess[str]`, `dict[str, str | None]`). +- `tests/test_setup_memory.py`: +4 новых теста — `test_wrapper_generated` (wrapper существует + executable), `test_wrapper_idempotent` (2 запуска → контент идентичен + "wrapper correct"), `test_wrapper_content` (содержит `python`, `-m`, `src.memory`, `spawnSync`, `process.argv.slice`), `test_wrapper_backup_original` (`.orig` сохраняет старый контент, НЕ перезаписывается при 2-й замене). +- `docs/project-map/README.md`: setup-memory.sh entry обновлён (6-step → 7-step, +JS wrapper), test_setup_memory.py entry обновлён (+wrapper gen/backup). +- ADR-033 + этот handoff. + +## Почему +PR #75 (merged) отрефакторил Memory CLI (`second-brain` → `memory`, OpenAI env, chunking, batching, dedup), но плагин `@mathew-cf/opencode-memory` всё ещё вызывал родной Rust `@mathew-cf/rag-cli` — 5-10 мин на 1.2 MB на CPU, 3 зомби-процесса зафиксировано, индекс никогда не записан (`$OPENCODE_MEMORY_DIR/.rag` отсутствовал). Плагин находит rag через `require.resolve("@mathew-cf/rag-cli/bin/rag.js")` (вендорный `dist/index.js:12703-12708`, НЕ патчим). + +Решение: setup-memory.sh генерирует JS wrapper по пути, который резолвит плагин. Wrapper = прагматичный shim (аналог pyenv/nvm shims), перехватывает `require.resolve` → перенаправляет вызовы в Python `memory` CLI. Идемпотентен (content check через `cmp -s`), backup `.orig` оригинала один раз. PR #2 в серии из 2 (после PR #75). + +Отклонение от спеки: спека говорила `spawnSync("python3", ["-m", "src.memory", ...])` — но system `python3` не имеет `httpx` (нет venv), CLI падает на import. Реализация выбирает venv-python (`${OPENCODE_WORKSPACE}/.venv/bin/python` → `.venv/bin/python` → `python3`), wrapper встраивает выбранный путь. Зафиксировано в ADR-033 Альтернативы. + +## Pending +— + +## Watch out +- **`$(cat)` vs `cmp -s` для idempotency.** Bash `$()` удаляет trailing newlines — `$(cat file)` возвращает контент без `\n` в конце, а `$VAR` (одиночные кавычки) содержит `\n` перед закрывающей `'`. Сравнение `[ "$(cat)" = "$VAR" ]` = false даже при идентичном контенте. Решение: `cmp -s "$WRAPPER_PATH" /dev/stdin <<<"$WRAPPER_CONTENT"` — байт-точное сравнение. НЕ возвращаться к `$(cat)`. +- **Wrapper встраивает абсолютный путь к Python.** `MEMORY_PYTHON` разрешается на момент запуска setup-memory.sh и встраивается в wrapper как literal string. Если venv переедет — wrapper нужно перегенерировать (удалить wrapper, запустить setup-memory.sh). Альтернатива (читать env в runtime через `process.env.MEMORY_WRAPPER_PYTHON`) — оставлена как override, но default встраивается для надёжности (env может быть не set в контексте плагина). +- **`MEMORY_WRAPPER_PATH` env override обязателен для тестов.** Без него скрипт пишет в `/usr/local/lib/node_modules/@mathew-cf/opencode-memory/...` — real plugin path. Все тесты передают `wrapper=tmp_path/"wrapper"/"rag.js"`. Smoke-test может использовать real path (если плагин установлен). +- **Smoke-test пройден (2026-07-26):** `bash .opencode/scripts/setup-memory.sh` с `MEMORY_WRAPPER_PATH=/tmp/...` → wrapper сгенерирован, `.rag/index.json` создан через облако (1 файл, ~42KB), idempotency подтверждена (2-й запуск → "wrapper correct"), backup `.orig` работает. Реальный plugin path НЕ тестировался (плагин может быть не установлен в тестовом окружении). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index c4834ae..e44a3e2 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -56,7 +56,7 @@ opencode-config/ │ │ ├── observability.py # OTel spans for tools │ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard) │ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs -│ │ ├── setup-memory.sh # opencode-memory bootstrap (deterministic 6-step flow, idempotent) — PR#36 +│ │ ├── setup-memory.sh # opencode-memory bootstrap (7-step: mkdir/clone/remote/hook/memory index/JS wrapper/status, idempotent) — PR#36, PR#77 │ │ ├── spec-status.py # 9-phase spec oracle │ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34 │ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools), plugins — PR#40, PR#69 @@ -110,7 +110,7 @@ opencode-config/ │ ├── test_post_review_tool.py # .opencode/tools/post-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65 │ ├── test_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65 │ ├── test_search.py # src/memory/search.py -│ ├── test_setup_memory.py # .opencode/scripts/setup-memory.sh (mock remote, idempotency) — PR#36 +│ ├── test_setup_memory.py # .opencode/scripts/setup-memory.sh (mock remote, idempotency, JS wrapper gen/backup) — PR#36, PR#77 │ ├── test_spec_status.py # .opencode/scripts/spec-status.py │ ├── test_spec_status_tool.py │ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65 diff --git a/tests/test_setup_memory.py b/tests/test_setup_memory.py index 948451d..7b1019c 100644 --- a/tests/test_setup_memory.py +++ b/tests/test_setup_memory.py @@ -4,7 +4,7 @@ Uses a local mock remote (``git init --bare``) instead of GitHub so the tests are hermetic and offline-safe. ``tmp_path`` provides isolation. The script is invoked via ``subprocess`` with env vars pointing at the -mock remote + a tmp dir. Each test asserts one of the 6 deterministic +mock remote + a tmp dir. Each test asserts one of the deterministic flow steps. Script contract (``setup-memory.sh``): @@ -12,7 +12,8 @@ Script contract (``setup-memory.sh``): 2. clone REMOTE | git pull --ff-only 3. remote set-url if origin != REMOTE 4. install post-commit hook (auto-push) if missing/wrong - 5. rag index if .rag missing (best-effort, rag optional) + 5. memory index if .rag missing (best-effort, CLI optional) + 5b. generate JS wrapper for opencode-memory plugin (idempotent + backup) 6. echo status Exit 1 when ``OPENCODE_MEMORY_REMOTE`` is unset (no default — the @@ -28,6 +29,7 @@ import pytest REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / ".opencode" / "scripts" / "setup-memory.sh" +REPO_PYTHON = str(REPO_ROOT / ".venv" / "bin" / "python") EXPECTED_HOOK = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n" @@ -64,11 +66,16 @@ def _seed_remote(remote_dir: Path) -> None: subprocess.run(["git", "-C", str(seed), "push", "-q", "origin", "master"], check=True) -def _run_script(memory_dir: Path, remote: str | None) -> subprocess.CompletedProcess: +def _run_script( + memory_dir: Path, + remote: str | None, + wrapper: Path | None = None, +) -> subprocess.CompletedProcess[str]: """Run setup-memory.sh with env pointing at tmp paths. When ``remote`` is None, OPENCODE_MEMORY_REMOTE is removed from env - (simulating the "no remote env" error path). + (simulating the "no remote env" error path). ``wrapper`` sets + ``MEMORY_WRAPPER_PATH`` so tests don't write to the real plugin dir. """ env = { **os.environ, @@ -79,6 +86,11 @@ def _run_script(memory_dir: Path, remote: str | None) -> subprocess.CompletedPro env["OPENCODE_MEMORY_REMOTE"] = remote else: env.pop("OPENCODE_MEMORY_REMOTE", None) + if wrapper is not None: + env["MEMORY_WRAPPER_PATH"] = str(wrapper) + wrapper.parent.mkdir(parents=True, exist_ok=True) + env["MEMORY_WRAPPER_PYTHON"] = REPO_PYTHON + env["OPENCODE_WORKSPACE"] = str(REPO_ROOT) return subprocess.run( ["bash", str(SCRIPT)], capture_output=True, @@ -93,7 +105,7 @@ def test_fresh_init(tmp_path: Path) -> None: remote = tmp_path / "remote.git" _seed_remote(remote) mem = tmp_path / "mem" - r = _run_script(mem, str(remote)) + r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js") assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" assert (mem / ".git").is_dir(), "repo not cloned" assert (mem / "README.md").exists(), "file not pulled" @@ -112,7 +124,7 @@ def test_existing_repo(tmp_path: Path) -> None: remote = tmp_path / "remote.git" _seed_remote(remote) mem = tmp_path / "mem" - assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 # capture state before second run head_before = subprocess.run( ["git", "-C", str(mem), "rev-parse", "HEAD"], @@ -120,7 +132,7 @@ def test_existing_repo(tmp_path: Path) -> None: text=True, check=True, ).stdout.strip() - r = _run_script(mem, str(remote)) + r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js") assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" head_after = subprocess.run( ["git", "-C", str(mem), "rev-parse", "HEAD"], @@ -137,14 +149,14 @@ def test_wrong_remote(tmp_path: Path) -> None: remote = tmp_path / "remote.git" _seed_remote(remote) mem = tmp_path / "mem" - assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 # corrupt remote URL (tmp path — avoids S108 insecure-tmp warning) wrong = tmp_path / "wrong-remote" subprocess.run( ["git", "-C", str(mem), "remote", "set-url", "origin", str(wrong)], check=True, ) - r = _run_script(mem, str(remote)) + r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js") assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" assert "fixing remote" in r.stdout got = subprocess.run( @@ -161,11 +173,11 @@ def test_missing_hook(tmp_path: Path) -> None: remote = tmp_path / "remote.git" _seed_remote(remote) mem = tmp_path / "mem" - assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 hook = mem / ".git" / "hooks" / "post-commit" hook.unlink() assert not hook.exists() - r = _run_script(mem, str(remote)) + r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js") assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" assert hook.exists(), "hook not recreated" assert hook.read_text() == EXPECTED_HOOK, f"hook content wrong: {hook.read_text()!r}" @@ -178,7 +190,7 @@ def test_idempotent(tmp_path: Path) -> None: _seed_remote(remote) mem = tmp_path / "mem" - def _snapshot() -> dict: + def _snapshot() -> dict[str, str | None]: head = subprocess.run( ["git", "-C", str(mem), "rev-parse", "HEAD"], capture_output=True, @@ -197,10 +209,10 @@ def test_idempotent(tmp_path: Path) -> None: ).stdout.strip(), } - assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 snap1 = _snapshot() - assert _run_script(mem, str(remote)).returncode == 0 - assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 + assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0 snap3 = _snapshot() assert snap1 == snap3, f"non-idempotent:\n run1={snap1}\n run3={snap3}" @@ -262,7 +274,11 @@ def test_insteadof_idempotent(tmp_path: Path) -> None: "GITHUB_TOKEN": "fake-token-for-test", "HOME": str(home), "GIT_TERMINAL_PROMPT": "0", + "MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"), + "MEMORY_WRAPPER_PYTHON": REPO_PYTHON, + "OPENCODE_WORKSPACE": str(REPO_ROOT), } + (tmp_path / "wrapper").mkdir(exist_ok=True) # First run: clone path triggers the insteadOf helper. r1 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env) @@ -306,8 +322,12 @@ def test_no_github_token_skips_insteadof(tmp_path: Path) -> None: "OPENCODE_MEMORY_REMOTE": str(remote), "HOME": str(home), "GIT_TERMINAL_PROMPT": "0", + "MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"), + "MEMORY_WRAPPER_PYTHON": REPO_PYTHON, + "OPENCODE_WORKSPACE": str(REPO_ROOT), } env.pop("GITHUB_TOKEN", None) + (tmp_path / "wrapper").mkdir(exist_ok=True) r = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env) assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" @@ -317,5 +337,74 @@ def test_no_github_token_skips_insteadof(tmp_path: Path) -> None: assert "insteadOf" not in content, "insteadOf set without GITHUB_TOKEN" +# ── JS wrapper for opencode-memory plugin (step 5b) ────────────────────────── + + +def test_wrapper_generated(tmp_path: Path) -> None: + """Run → wrapper file exists at MEMORY_WRAPPER_PATH with delegate content.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + wrapper = tmp_path / "wrapper" / "rag.js" + r = _run_script(mem, str(remote), wrapper=wrapper) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + assert wrapper.exists(), f"wrapper not generated at {wrapper}" + assert wrapper.stat().st_mode & 0o100, "wrapper not executable" + assert "generated wrapper" in r.stdout, f"missing generate msg: {r.stdout!r}" + + +def test_wrapper_idempotent(tmp_path: Path) -> None: + """Run 2x → wrapper content identical, 2nd run reports 'wrapper correct'.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + wrapper = tmp_path / "wrapper" / "rag.js" + r1 = _run_script(mem, str(remote), wrapper=wrapper) + assert r1.returncode == 0, f"run1: stdout={r1.stdout}\nstderr={r1.stderr}" + content1 = wrapper.read_text() + r2 = _run_script(mem, str(remote), wrapper=wrapper) + assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}" + content2 = wrapper.read_text() + assert content1 == content2, "wrapper content changed between runs" + assert "wrapper correct" in r2.stdout, f"2nd run should report correct: {r2.stdout!r}" + + +def test_wrapper_content(tmp_path: Path) -> None: + """Wrapper contains python memory CLI delegation + spawnSync.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + wrapper = tmp_path / "wrapper" / "rag.js" + r = _run_script(mem, str(remote), wrapper=wrapper) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + body = wrapper.read_text() + assert "python" in body, f"wrapper missing python ref: {body!r}" + assert "-m" in body and "src.memory" in body, "wrapper missing src.memory module" + assert "spawnSync" in body, "wrapper missing spawnSync" + assert "process.argv.slice" in body, "wrapper missing argv passthrough" + + +def test_wrapper_backup_original(tmp_path: Path) -> None: + """Pre-existing wrapper with different content → .orig backup saved once.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + wrapper = tmp_path / "wrapper" / "rag.js" + wrapper.parent.mkdir(parents=True, exist_ok=True) + original = "# old rag-cli shim\nconsole.log('old');\n" + wrapper.write_text(original) + r = _run_script(mem, str(remote), wrapper=wrapper) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + backup = wrapper.with_suffix(".js.orig") + assert backup.exists(), ".orig backup not created" + assert backup.read_text() == original, ".orig does not preserve original content" + assert wrapper.read_text() != original, "wrapper not replaced with delegate" + # Second run with different wrong content: .orig must NOT be overwritten. + wrapper.write_text("# another wrong\n") + r2 = _run_script(mem, str(remote), wrapper=wrapper) + assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}" + assert backup.read_text() == original, ".orig overwritten on 2nd backup" + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"]))