feat(memory): add memory-setup tool with deterministic init (#36)

* feat(memory): add memory-setup tool and rewrite script

* fix(memory): correct OPENCODE_MEMORY_DIR in docker-compose

* test(memory): add setup-memory tests with mock remote

* docs(memory): add ADR and handoff

* docs(handoff): set PR number

* fix(docs): correct handoff and ADR filenames to PR number

* docs: update project map + handoff + ADR

* fix(memory): address code review comments

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
Sergey 2026-07-24 03:26:16 +03:00 committed by GitHub
parent efc0827226
commit 2b9ee23b49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 546 additions and 37 deletions

View file

@ -27,3 +27,8 @@ TUNNEL_DOMAIN=
# Local: http://localhost:8765/mcp # Local: http://localhost:8765/mcp
# Remote: http://<your-server-ip>:8765/mcp # Remote: http://<your-server-ip>:8765/mcp
ANTIDETECT_BROWSER_MCP_URL=http://localhost:8765/mcp ANTIDETECT_BROWSER_MCP_URL=http://localhost:8765/mcp
# OpenCode Memory (required — memory-setup tool exits 1 if OPENCODE_MEMORY_REMOTE unset)
# Point at your fork of opencode-memory (see README "Memory setup" for the upstream repo)
OPENCODE_MEMORY_REMOTE=https://github.com/your-username/your-opencode-memory.git
OPENCODE_MEMORY_DIR=/root/.local/share/opencode/opencode-memory

3
.gitignore vendored
View file

@ -8,6 +8,9 @@ app_data/workspaces/*
app_data/ssh/* app_data/ssh/*
!app_data/ssh/.gitkeep !app_data/ssh/.gitkeep
# opencode-memory lives in app_data/ as a separate git repo (clone target of memory-setup)
app_data/opencode-memory/
# opencode plugin node_modules # opencode plugin node_modules
.opencode/node_modules/ .opencode/node_modules/

View file

@ -1,46 +1,80 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
MEMORY_DIR="${OPENCODE_MEMORY_DIR}" MEMORY_DIR="${OPENCODE_MEMORY_DIR:-/root/.local/share/opencode/opencode-memory}"
REMOTE="${OPENCODE_MEMORY_REMOTE:-https://github.com/slaid098/opencode-memory.git}" HOOK="$MEMORY_DIR/.git/hooks/post-commit"
BRANCH="master"
EXPECTED_HOOK="#!/bin/bash
git push origin ${BRANCH} 2>/dev/null || true"
if [ -z "$MEMORY_DIR" ]; then if [ -z "${OPENCODE_MEMORY_REMOTE:-}" ]; then
echo "ERROR: OPENCODE_MEMORY_DIR not set" echo "ERROR: OPENCODE_MEMORY_REMOTE not set" >&2
exit 1 exit 1
fi fi
REMOTE="${OPENCODE_MEMORY_REMOTE}"
echo "Setting up memory repo at: $MEMORY_DIR" echo "memory setup: $MEMORY_DIR"
# 1. If .git doesn't exist — init + pull # 1. MEMORY_DIR exists? → mkdir -p
if [ ! -d "$MEMORY_DIR/.git" ]; then if [ ! -d "$MEMORY_DIR" ]; then
echo "Initializing new memory repo..."
mkdir -p "$MEMORY_DIR" mkdir -p "$MEMORY_DIR"
git init "$MEMORY_DIR" echo " [1/6] created directory: $MEMORY_DIR"
git -C "$MEMORY_DIR" remote add origin "$REMOTE"
git -C "$MEMORY_DIR" pull origin master 2>/dev/null || echo "No remote memory yet — starting fresh"
else else
echo "Memory repo already exists." echo " [1/6] directory exists"
# 2. If remote not configured — add it
if ! git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null; then
git -C "$MEMORY_DIR" remote add origin "$REMOTE"
fi
# 3. Pull latest changes
echo "Pulling latest memory..."
git -C "$MEMORY_DIR" pull --rebase origin master 2>/dev/null || echo "Pull failed — continuing with local state"
fi fi
# 4. Install post-commit hook for auto-push # 2. .git exists? → clone (no) | pull --ff-only (yes)
HOOK="$MEMORY_DIR/.git/hooks/post-commit" if [ ! -d "$MEMORY_DIR/.git" ]; then
echo "Installing post-commit hook for auto-push..." echo " [2/6] cloning remote: $REMOTE"
cat > "$HOOK" << 'EOF' git clone --origin origin "$REMOTE" "$MEMORY_DIR" || { echo "ERROR: clone failed" >&2; exit 1; }
#!/bin/bash git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true
git push origin master 2>/dev/null || true else
EOF echo " [2/6] pulling latest (ff-only)"
git -C "$MEMORY_DIR" pull --ff-only origin "$BRANCH" 2>/dev/null || \
echo " pull skipped (no upstream or offline)"
fi
# 3. remote origin correct? → set-url (no) | noop (yes)
CURRENT_REMOTE="$(git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null || echo "")"
if [ "$CURRENT_REMOTE" != "$REMOTE" ]; then
echo " [3/6] fixing remote: $CURRENT_REMOTE$REMOTE"
git -C "$MEMORY_DIR" remote set-url origin "$REMOTE"
else
echo " [3/6] remote correct"
fi
# 4. post-commit hook exists + content correct? → create/fix (no) | noop (yes)
NEEDS_HOOK=0
if [ ! -f "$HOOK" ]; then
NEEDS_HOOK=1
elif [ "$(cat "$HOOK")" != "$EXPECTED_HOOK" ]; then
NEEDS_HOOK=1
fi
if [ "$NEEDS_HOOK" -eq 1 ]; then
echo " [4/6] installing post-commit hook (auto-push)"
mkdir -p "$(dirname "$HOOK")"
printf '%s\n' "$EXPECTED_HOOK" > "$HOOK"
chmod +x "$HOOK" chmod +x "$HOOK"
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
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"
else
echo " [5/6] RAG index exists"
fi
else
echo " [5/6] rag CLI not installed — skipping index"
fi
# 6. status
echo " [6/6] done"
echo "" echo ""
echo "Memory repo configured successfully." echo "memory: ready at $MEMORY_DIR"
echo " Remote: $REMOTE" echo " remote: $REMOTE"
echo " Auto-push: enabled (post-commit hook)" echo " branch: $BRANCH"
echo "" echo " auto-push: enabled (post-commit hook)"
echo "memory_save() will now auto-push after each commit."

View file

@ -0,0 +1,19 @@
import { spawnSync } from "child_process"
import path from "path"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Initialize or sync opencode-memory. Clones remote repo, installs post-commit hook for auto-push, rebuilds RAG index. Idempotent — safe to run multiple times. No arguments needed.",
args: {},
async execute(_args, context) {
const script = path.join(import.meta.dir, "..", "scripts", "setup-memory.sh")
const r = spawnSync("bash", [script], {
encoding: "utf-8",
cwd: context.worktree,
})
if (r.status !== 0) {
return `⚠️ memory-setup failed (exit ${r.status}): ${r.stderr || r.stdout}`
}
return r.stdout.trim()
},
})

View file

@ -59,6 +59,7 @@ Copy `.env.example` to `.env` and fill in:
Memory uses `@mathew-cf/opencode-memory` plugin (hybrid search: ripgrep + local RAG). Memory uses `@mathew-cf/opencode-memory` plugin (hybrid search: ripgrep + local RAG).
- `OPENCODE_MEMORY_DIR` env var points to memory directory (default: `app_data/opencode-memory/`) - `OPENCODE_MEMORY_DIR` env var points to memory directory (default: `app_data/opencode-memory/`)
- `OPENCODE_MEMORY_REMOTE` must point at your git remote — fork the upstream [`slaid098/opencode-memory`](https://github.com/slaid098/opencode-memory) repo and set the URL in `.env`
- Run `.opencode/scripts/setup-memory.sh` to initialize memory repo - Run `.opencode/scripts/setup-memory.sh` to initialize memory repo
## License ## License

View file

@ -30,7 +30,7 @@ services:
environment: environment:
- DOCKER_HOST=tcp://docker-dind:2375 - DOCKER_HOST=tcp://docker-dind:2375
- OPENCODE_CONFIG_DIR=/root/.config/opencode - OPENCODE_CONFIG_DIR=/root/.config/opencode
- OPENCODE_MEMORY_DIR=/root/workspace/app_data/opencode-memory - OPENCODE_MEMORY_DIR=/root/.local/share/opencode/opencode-memory
- DOCKER_CONTAINER=true - DOCKER_CONTAINER=true
- GIT_AUTHOR_NAME=opencode-agent - GIT_AUTHOR_NAME=opencode-agent
- GIT_AUTHOR_EMAIL=agent@opencode.local - GIT_AUTHOR_EMAIL=agent@opencode.local

View file

@ -0,0 +1,28 @@
# ADR-014: Memory setup tool with deterministic init
## Статус
Accepted
## Контекст
После миграции memory в отдельный репо `slaid098/opencode-memory` (PR#17/24) память стала невидима для агента в Docker-окружении. `docker-compose.yml` указывал `OPENCODE_MEMORY_DIR=/root/workspace/app_data/opencode-memory`, но mount `./app_data/workspaces:/root/workspace` делал путь несуществующим — memory физически лежала вне доступного контейнеру пути. Ручная инициализация (clone, hook, rag index) не воспроизводилась детерминированно: агент не должен импровизировать bash-команды, а raw скрипт `setup-memory.sh` (PR#24) не был idempotent и не покрывал edge-cases (wrong remote, missing hook, offline pull).
Нужен tool, который агент вызывает из главного чата (pure-orchestrator model, ADR-010): tool делегирует в bash script, script детерминированно приводит memory в known-good state за 6 шагов.
## Решение
TS tool `memory-setup.ts` (thin wrapper, 0 args, паттерн `tunnel.ts`) + bash script `setup-memory.sh` (детерминированный flow, `set -euo pipefail`):
1. `mkdir -p MEMORY_DIR` — если директории нет
2. `git clone` (если нет `.git`) | `git pull --ff-only` (если есть)
3. `git remote set-url` — если origin ≠ REMOTE
4. post-commit hook install — если отсутствует или содержимое ≠ expected
5. `rag index` — если `.rag` нет (best-effort, rag CLI опционален)
6. `echo status`
Idempotent: 3x прогона = одинаковый state. `OPENCODE_MEMORY_REMOTE` обязательна (exit 1 если не set) — `.env.example` предоставляет значение; отсутствие = config error, не silent fallback. `OPENCODE_MEMORY_DIR` имеет default `/root/.local/share/opencode/opencode-memory` (через существующий `./app_data:/root/.local/share/opencode` mount).
post-commit hook: `#!/bin/bash\ngit push origin master 2>/dev/null || true` — auto-push после каждого `memory_save`, синхронизирует memory-репо с remote без ручного шага.
## Альтернативы
- Raw bash (агент вызывает `setup-memory.sh` напрямую через bash permission) — отклонено: нарушает pure-orchestrator model (ADR-010), агент не должен запускать arbitrary bash из главного чата
- MCP plugin init hook (auto-init при старте opencode) — отклонено: plugin init не ставит post-commit hook и не перестраивает RAG index; нет явного control когда init происходит
- Default value для `OPENCODE_MEMORY_REMOTE` (silent fallback на `https://github.com/slaid098/opencode-memory.git`) — отклонено: спека требует exit 1 при отсутствии env var; silent fallback маскирует config errors (пользователь может случайно клонировать чужой репо)

View file

@ -0,0 +1,35 @@
---
pr_number: 36
title: Memory setup tool with deterministic init
---
# PR: Memory setup tool with deterministic init
## Что сделано
- `.opencode/tools/memory-setup.ts` — TS tool wrapper (0 args, вызывает `bash .opencode/scripts/setup-memory.sh`, паттерн `tunnel.ts`)
- `.opencode/scripts/setup-memory.sh` — переписан: детерминированный flow (6 шагов: mkdir, clone/pull, remote check, hook, rag index, status), `set -euo pipefail`, idempotent
- `docker-compose.yml``OPENCODE_MEMORY_DIR` исправлен: `/root/workspace/app_data/opencode-memory``/root/.local/share/opencode/opencode-memory` (через существующий `./app_data:/root/.local/share/opencode` mount)
- `.env.example` — добавлены `OPENCODE_MEMORY_REMOTE` (обязательный) + `OPENCODE_MEMORY_DIR` (опциональный, default в script)
- `.gitignore` — добавлен `app_data/opencode-memory/` (memory — отдельный репо, clone target, не в config)
- `tests/test_setup_memory.py` — 6 pytest тестов с mock remote (`git init --bare`): fresh init, existing repo, wrong remote, missing hook, idempotent (3x), no remote env
- `tests/test_memory_setup_tool.ts` — 2 TS теста (документационные, по паттерну `test_pipeline_status_tool.ts`): success, failure
- `tests/test_memory_setup_tool.py` — 4 Python теста через `_ts_loader.mjs`: load, success, failure, cwd propagation
- ADR-014 + этот handoff
## Почему
После миграции memory в отдельный репо (`slaid098/opencode-memory`) память стала невидима в Docker: `docker-compose.yml` указывал путь через mount `./app_data/workspaces:/root/workspace`, но memory лежала вне этого mount. Ручная инициализация не воспроизводилась детерминированно. Нужен tool для pure-orchestrator model (ADR-010): агент вызывает tool из главного чата, tool делегирует в script, script клонирует remote, ставит post-commit hook для auto-push, перестраивает RAG index. Idempotent — каждый запуск = одинаковый result, безопасно вызывать многократно.
Спека issue содержала противоречие: одновременно указан default для `OPENCODE_MEMORY_REMOTE` (`https://github.com/slaid098/opencode-memory.git`) и требование "exit 1 если не set". Решено в пользу acceptance criteria + test spec: env var обязательна, default убран, `.env.example` предоставляет значение. Зафиксировано в ADR-014 (Альтернативы).
## Pending
- `rag` CLI нет на CI runner — шаг 5 скрипта best-effort (warn + continue, не exit 1). Когда rag будет установлен в Dockerfile, index будет перестраиваться автоматически. Вне scope этого PR.
- `master` branch hardcoded в скрипте и hook. Memory remote использует `master` (не `main`). Если remote переедет на `main` — потребуется обновление `BRANCH` переменной.
## Watch out
- `OPENCODE_MEMORY_REMOTE` обязателен — без него скрипт exit 1. Не добавляйте default обратно: silent fallback маскирует config errors (можно клонировать чужой репо).
- `OPENCODE_MEMORY_DIR` default = `/root/.local/share/opencode/opencode-memory` — соответствует mount `./app_data:/root/.local/share/opencode` в docker-compose.yml. Memory физически лежит в `opencode-config/app_data/opencode-memory/` на хосте (отдельный git репо, gitignored).
- post-commit hook выполняет `git push origin master 2>/dev/null || true` — silent failure при offline. Memory commits сохраняются локально, push произойдёт при следующем online-коммите.
- `git pull --ff-only` — если локальная история разошлась с remote (force-push или rebase), pull упадёт. Скрипт ловит это (`|| echo skipped`) и продолжает, но memory останется неактуальной. Ручное разрешение требуется.
- `rag index` шаг best-effort: если `rag` CLI не установлен, скрипт пропускает шаг (warn, не error). RAG index перестроится когда rag станет доступен.
- Tools auto-discovered через @opencode-ai/plugin — НЕ нужно регистрировать в opencode.json.
- TS-тест (`test_memory_setup_tool.ts`) документационный — CI гоняет Python-версию (`test_memory_setup_tool.py`) через `_ts_loader.mjs` (bun нет на runner).

View file

@ -40,6 +40,7 @@ opencode-config/
│ │ └── spec/SKILL.md # 9-phase spec generation │ │ └── spec/SKILL.md # 9-phase spec generation
│ ├── tools/ │ ├── tools/
│ │ ├── merge-pr.ts # merge_pr tool wrapper (orchestrator-safe gh pr merge) — PR#30 │ │ ├── merge-pr.ts # merge_pr tool wrapper (orchestrator-safe gh pr merge) — PR#30
│ │ ├── memory-setup.ts # memory_setup tool wrapper (0 args, calls setup-memory.sh) — PR#36
│ │ ├── pipeline-status.ts # pipeline_status tool wrapper │ │ ├── pipeline-status.ts # pipeline_status tool wrapper
│ │ ├── spec-status.ts # spec_status tool wrapper │ │ ├── spec-status.ts # spec_status tool wrapper
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34 │ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
@ -49,7 +50,7 @@ opencode-config/
│ │ ├── observability.py # OTel spans for tools │ │ ├── observability.py # OTel spans for tools
│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling) │ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling)
│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs │ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs
│ │ ├── setup-memory.sh # opencode-memory bootstrap │ │ ├── setup-memory.sh # opencode-memory bootstrap (deterministic 6-step flow, idempotent) — PR#36
│ │ ├── spec-status.py # 9-phase spec oracle │ │ ├── spec-status.py # 9-phase spec oracle
│ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34 │ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34
│ ├── opencode.json # MCP servers, providers, permissions, agents, plugins │ ├── opencode.json # MCP servers, providers, permissions, agents, plugins
@ -74,6 +75,8 @@ opencode-config/
│ ├── test_cli.py # src/memory/cli.py │ ├── test_cli.py # src/memory/cli.py
│ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL) │ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL)
│ ├── test_index.py # src/memory/index.py │ ├── test_index.py # src/memory/index.py
│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36
│ ├── test_memory_setup_tool.ts # TS wrapper test (mjs loader) — PR#36
│ ├── test_observability.py # .opencode/scripts/observability.py │ ├── test_observability.py # .opencode/scripts/observability.py
│ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching) │ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching)
│ ├── test_pipeline_status_adr.py │ ├── test_pipeline_status_adr.py
@ -81,6 +84,7 @@ opencode-config/
│ ├── test_pipeline_status_tool.py │ ├── test_pipeline_status_tool.py
│ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader) │ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader)
│ ├── test_search.py # src/memory/search.py │ ├── test_search.py # src/memory/search.py
│ ├── test_setup_memory.py # .opencode/scripts/setup-memory.sh (mock remote, idempotency) — PR#36
│ ├── test_spec_status.py # .opencode/scripts/spec-status.py │ ├── test_spec_status.py # .opencode/scripts/spec-status.py
│ └── test_spec_status_tool.py │ └── test_spec_status_tool.py
├── pyproject.toml # Python project (uv, ruff, pytest config) ├── pyproject.toml # Python project (uv, ruff, pytest config)
@ -88,8 +92,9 @@ opencode-config/
├── .pre-commit-config.yaml # ruff + UV hooks ├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts — PR#24 ├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts — PR#24
├── Dockerfile # node:20-slim + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared — PR#24, PR#34 ├── Dockerfile # node:20-slim + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared — PR#24, PR#34
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN) ├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR)
├── app_data/ ├── app_data/
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
│ ├── workspaces/ # Agent working directory (.gitkeep) │ ├── workspaces/ # Agent working directory (.gitkeep)
│ └── ssh/ # SSH keys, not in git (.gitkeep) │ └── ssh/ # SSH keys, not in git (.gitkeep)
├── .editorconfig ├── .editorconfig

View file

@ -0,0 +1,104 @@
"""Tests for .opencode/tools/memory-setup.ts — the memory-setup custom tool.
Mirrors ``tests/test_pipeline_status_tool.py`` / ``test_spec_status_tool.py``:
exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs``
(a node CommonJS sandbox that strips TS-only syntax, stubs
``@opencode-ai/plugin``, and replaces ``import.meta.dir`` with the real
``.opencode/tools`` directory).
The loader is parameterized via the ``TS_FILE`` env var so the same harness
works for every TS tool wrapper. These tests set
``TS_FILE=.opencode/tools/memory-setup.ts``.
Modes used (same as the pipeline-status / spec-status tool tests):
- ``load`` sanity-check that the tool loads and declares no args.
- ``exec_stub`` call execute with a stubbed spawnSync to verify:
(a) success path: exit 0 trimmed stdout returned,
(b) failure path: exit 1 `` memory-setup failed (exit 1): <stderr>``.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "memory-setup.ts"
TS_FILE_REL = ".opencode/tools/memory-setup.ts"
def _run_loader(*args: str) -> dict:
"""Invoke the loader with ``TS_FILE`` env set to memory-setup.ts and parse JSON stdout."""
env = {**os.environ, "TS_FILE": TS_FILE_REL}
proc = subprocess.run(
["node", str(LOADER), *args],
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
timeout=60,
env=env,
)
if proc.returncode != 0:
raise RuntimeError(
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
)
return json.loads(proc.stdout)
def test_loader_can_load_tool():
"""Sanity: memory-setup.ts loads and declares no args (toggle-style tool)."""
if not TS_FILE.exists():
pytest.skip("memory-setup.ts not present")
out = _run_loader("load")
assert "description" in out
assert out["args"] == [], f"expected no args, got: {out['args']}"
def test_success():
"""execute() with exit 0 returns trimmed stdout.
memory-setup.ts follows the tunnel.ts pattern: ``r.stdout.trim()`` on
success. Stub spawnSync to return ``" memory: ready \\n"`` and verify
the tool returns ``"memory: ready"`` (whitespace stripped).
"""
out = _run_loader("exec_stub", "_", "0", " memory: ready \n", "")
result = out["result"]
assert result == "memory: ready", f"expected trimmed stdout, got: {result!r}"
def test_failure():
"""execute() with exit 1 returns ``⚠️ memory-setup failed (exit 1): <stderr>``.
The tool prepends `` memory-setup failed (exit <N>): `` to the stderr
(falling back to stdout if stderr is empty). Stub spawnSync to return
exit 1 + a stderr string and verify the error prefix + stderr content.
"""
out = _run_loader("exec_stub", "_", "1", "", "ERROR: OPENCODE_MEMORY_REMOTE not set")
result = out["result"]
assert "⚠️ memory-setup failed" in result, f"missing error prefix: {result!r}"
assert "exit 1" in result, f"missing exit code: {result!r}"
assert "OPENCODE_MEMORY_REMOTE not set" in result, f"missing stderr: {result!r}"
def test_execute_uses_cwd_from_context():
"""execute passes ``cwd=context.worktree`` to spawnSync (ADR-023 pattern).
Like pipeline-status.ts / spec-status.ts / tunnel.ts, the memory-setup
tool wrapper propagates ``context.worktree`` as ``cwd`` to spawnSync so
the bash script runs in the git worktree root (where .opencode/ lives),
not in the opencode process cwd.
"""
out = _run_loader("exec_stub", "_", "0", "ok", "")
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
opts = calls[0]["opts"]
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
assert opts["cwd"] == str(REPO_ROOT), (
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
)

View file

@ -0,0 +1,56 @@
/**
* Tests for .opencode/tools/memory-setup.ts the memory-setup custom tool.
*
* Mirror of tests/test_pipeline_status_tool.ts / test_spec_status_tool.ts:
* the tool is a thin spawnSync wrapper around
* ``.opencode/scripts/setup-memory.sh`` with no args.
*
* Runtime note: opencode ships a standalone binary with Bun bundled inside;
* there is no separate ``bun`` CLI on the host (CI runner uses node + pytest).
* The CI runs the equivalent Python tests in
* ``tests/test_memory_setup_tool.py`` via the JS loader ``tests/_ts_loader.mjs``.
* This file documents the intended TS-side test cases and is runnable under
* ``bun test`` once a bun runtime is available on the host.
*
* Test cases (mirror tests/test_memory_setup_tool.py):
* - test_success script exit 0 returns trimmed stdout
* - test_failure script exit 1 returns `` memory-setup failed (exit 1): <stderr>``
*/
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
import { spawnSync } from "child_process"
import path from "path"
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "memory-setup.ts")
describe("memory-setup tool", () => {
test("test_success — script exit 0 returns trimmed stdout", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: " memory: ready \n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({}, {
sessionID: "t", messageID: "t", agent: "t",
directory: ".", worktree: ".",
abort: new AbortController().signal,
metadata() {}, async ask() {},
})
expect(result).toBe("memory: ready")
})
test("test_failure — script exit 1 returns error message", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 1, stdout: "", stderr: "ERROR: OPENCODE_MEMORY_REMOTE not set" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({}, {
sessionID: "t", messageID: "t", agent: "t",
directory: ".", worktree: ".",
abort: new AbortController().signal,
metadata() {}, async ask() {},
})
expect(result).toContain("⚠️ memory-setup failed")
expect(result).toContain("exit 1")
expect(result).toContain("OPENCODE_MEMORY_REMOTE not set")
})
})

219
tests/test_setup_memory.py Normal file
View file

@ -0,0 +1,219 @@
"""Tests for .opencode/scripts/setup-memory.sh — deterministic memory init.
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
flow steps.
Script contract (``setup-memory.sh``):
1. mkdir -p MEMORY_DIR
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)
6. echo status
Exit 1 when ``OPENCODE_MEMORY_REMOTE`` is unset (no default the
``.env.example`` provides the value; absence is a config error).
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / ".opencode" / "scripts" / "setup-memory.sh"
EXPECTED_HOOK = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n"
def _seed_remote(remote_dir: Path) -> None:
"""Seed a bare remote with one commit on ``master``."""
remote_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "init", "--bare", "-q", str(remote_dir)], check=True)
seed = remote_dir.parent / "seed"
seed.mkdir()
subprocess.run(["git", "init", "-q", str(seed)], check=True)
(seed / "README.md").write_text("# memory\n")
subprocess.run(["git", "-C", str(seed), "add", "README.md"], check=True)
subprocess.run(
[
"git",
"-C",
str(seed),
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
],
check=True,
)
subprocess.run(["git", "-C", str(seed), "branch", "-M", "master"], check=True)
subprocess.run(
["git", "-C", str(seed), "remote", "add", "origin", str(remote_dir)],
check=True,
)
subprocess.run(["git", "-C", str(seed), "push", "-q", "origin", "master"], check=True)
def _run_script(memory_dir: Path, remote: str | None) -> subprocess.CompletedProcess:
"""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).
"""
env = {
**os.environ,
"OPENCODE_MEMORY_DIR": str(memory_dir),
"GIT_TERMINAL_PROMPT": "0",
}
if remote is not None:
env["OPENCODE_MEMORY_REMOTE"] = remote
else:
env.pop("OPENCODE_MEMORY_REMOTE", None)
return subprocess.run(
["bash", str(SCRIPT)],
capture_output=True,
text=True,
check=False,
env=env,
)
def test_fresh_init(tmp_path: Path) -> None:
"""Empty dir → run → clone, hook, remote, files present."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
r = _run_script(mem, str(remote))
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"
assert (mem / ".git" / "hooks" / "post-commit").exists(), "hook missing"
got = subprocess.run(
["git", "-C", str(mem), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert got == str(remote), f"remote mismatch: {got}"
def test_existing_repo(tmp_path: Path) -> None:
""".git exists → run → pull --ff-only, no destructive changes."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
assert _run_script(mem, str(remote)).returncode == 0
# capture state before second run
head_before = subprocess.run(
["git", "-C", str(mem), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
r = _run_script(mem, str(remote))
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
head_after = subprocess.run(
["git", "-C", str(mem), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert head_before == head_after, "pull --ff-only changed HEAD unexpectedly"
assert "pulling latest" in r.stdout
def test_wrong_remote(tmp_path: Path) -> None:
"""remote ≠ expected → run → set-url corrects the remote."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
assert _run_script(mem, str(remote)).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))
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
assert "fixing remote" in r.stdout
got = subprocess.run(
["git", "-C", str(mem), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert got == str(remote), f"remote not fixed: {got}"
def test_missing_hook(tmp_path: Path) -> None:
"""hook missing → run → hook created with correct content."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
assert _run_script(mem, str(remote)).returncode == 0
hook = mem / ".git" / "hooks" / "post-commit"
hook.unlink()
assert not hook.exists()
r = _run_script(mem, str(remote))
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}"
assert "installing post-commit hook" in r.stdout
def test_idempotent(tmp_path: Path) -> None:
"""run 3x → state identical after run 1 and run 3."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
def _snapshot() -> dict:
head = subprocess.run(
["git", "-C", str(mem), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
hook = mem / ".git" / "hooks" / "post-commit"
return {
"head": head,
"hook": hook.read_text() if hook.exists() else None,
"remote": subprocess.run(
["git", "-C", str(mem), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip(),
}
assert _run_script(mem, str(remote)).returncode == 0
snap1 = _snapshot()
assert _run_script(mem, str(remote)).returncode == 0
assert _run_script(mem, str(remote)).returncode == 0
snap3 = _snapshot()
assert snap1 == snap3, f"non-idempotent:\n run1={snap1}\n run3={snap3}"
def test_no_remote_env(tmp_path: Path) -> None:
"""OPENCODE_MEMORY_REMOTE unset → exit 1, error message to stderr/stdout."""
mem = tmp_path / "mem"
r = _run_script(mem, remote=None)
assert r.returncode == 1, f"expected exit 1, got {r.returncode}"
combined = r.stderr + r.stdout
assert "OPENCODE_MEMORY_REMOTE" in combined, f"missing error msg: {combined!r}"
assert "ERROR" in combined or "not set" in combined
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))