chore(memory): remove plugin + setup-memory.sh + Rust artifacts (#103)

* chore(memory): remove plugin block from opencode.json

* chore(memory): remove setup-memory.sh and memory-setup.ts

* chore(memory): remove opencode-memory from Dockerfile

* docs(memory): update AGENTS.md and SKILL.md for new tools

* test(memory): drop tests for removed setup-memory.sh and memory-setup.ts

* docs(handoff): add handoff and ADR-045 for plugin/rust removal

* docs(handoff): set PR number

* docs(project-map): update after plugin/rust removal

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-27 01:47:26 +03:00 committed by GitHub
parent 9d1373a51b
commit 9d654a2201
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 198 additions and 774 deletions

View file

@ -1,13 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@mathew-cf/opencode-memory",
{
"memoryDir": "{env:OPENCODE_MEMORY_DIR}"
}
]
],
"skills": {
"paths": [
".opencode/skills"

View file

@ -1,127 +0,0 @@
#!/usr/bin/env bash
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"
if [ -z "${OPENCODE_MEMORY_REMOTE:-}" ]; then
echo "ERROR: OPENCODE_MEMORY_REMOTE not set" >&2
exit 1
fi
REMOTE="${OPENCODE_MEMORY_REMOTE}"
echo "memory setup: $MEMORY_DIR"
# 1. MEMORY_DIR exists? → mkdir -p
if [ ! -d "$MEMORY_DIR" ]; then
mkdir -p "$MEMORY_DIR"
echo " [1/6] created directory: $MEMORY_DIR"
else
echo " [1/6] directory exists"
fi
# 2. .git exists? → clone (no) | pull --ff-only (yes)
if [ ! -d "$MEMORY_DIR/.git" ]; then
echo " [2/6] cloning remote: $REMOTE"
# Ensure GITHUB_TOKEN is used for HTTPS git operations (non-interactive).
# Idempotent: git config --global overwrites (not duplicates) the value.
if [ -n "${GITHUB_TOKEN:-}" ]; then
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf https://github.com/
fi
git clone --origin origin "$REMOTE" "$MEMORY_DIR" || { echo "ERROR: clone failed" >&2; exit 1; }
git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true
else
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"
else
echo " [4/6] hook correct"
fi
# 5. .rag/index.json exists? → memory index (no) | noop (yes)
if "$MEMORY_PYTHON" -c "import src.memory" >/dev/null 2>&1; then
if [ ! -f "$MEMORY_DIR/.rag/index.json" ]; then
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/7] RAG index exists"
fi
else
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 " [7/7] done"
echo ""
echo "memory: ready at $MEMORY_DIR"
echo " remote: $REMOTE"
echo " branch: $BRANCH"
echo " auto-push: enabled (post-commit hook)"

View file

@ -6,17 +6,17 @@ description: Инструкция по работе с файловой памя
# File Memory (opencode-memory)
Графовая память (Graphiti/FalkorDB) удалена — была нестабильна и забагована.
Теперь память работает через `@mathew-cf/opencode-memory` — файловая система с keyword + semantic search.
Память работает через файловую систему с keyword + semantic search — 5 TS tools (`.opencode/tools/memory-*.ts`), вызывают `python3 -m src.memory`. Плагин `@mathew-cf/opencode-memory` удалён.
## Инструменты
| Инструмент | Назначение |
|---|---|
| `memory_search(query, category?)` | Гибридный поиск (keyword + semantic) |
| `memory_list(category?)` | Список категорий / файлов |
| `memory_save()` | Commit + re-index после записи/редактирования |
| `memory_access(path)` | Отметить файл как прочитанный |
| `memory-setup()` | Проверить статус бэкендов |
| `memory-search({ query, category? })` | Гибридный поиск (ripgrep keyword + Python semantic) |
| `memory-list({ category? })` | Список категорий / файлов |
| `memory-save()` | Commit + reindex после записи/редактирования (auto-setup: git init + hook если `OPENCODE_MEMORY_REMOTE` set) |
| `memory-access({ path })` | Отметить файл как прочитанный (bump `last_accessed`/`access_count`) |
| `memory-doctor()` | Read-only диагностика: ripgrep, Python `src.memory`, env vars, index |
## Категории
@ -24,7 +24,7 @@ description: Инструкция по работе с файловой памя
## Как работать
1. **Перед началом работы**`memory_search` по теме
1. **Перед началом работы**`memory-search` по теме
2. **В процессе** — сохранять находки сразу (контекст свежий)
3. **В конце сессии** — retrospective: что узнал → сохранить, что было в памяти → обновить, чего не хватало → создать
@ -61,7 +61,7 @@ related: [category/file.md]
## Путь для репозиториев
Плагинный default: `~/opencode-memory` (переопределяется через `OPENCODE_MEMORY_DIR`, например `app_data/opencode-memory`).
Default: `/root/.local/share/opencode/opencode-memory` (переопределяется через `OPENCODE_MEMORY_DIR`).
```
{memory-dir}/repos/{host}/{org}/{repo}.md

View file

@ -1,19 +0,0 @@
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

@ -35,7 +35,11 @@ fallback на raw bash, НЕ импровизируй обход через `gh
| `post-docs-review({ pr_number, verdict, body })` | `gh pr comment` для verdict | Docs review verdict (heading `## Docs Review Summary` + verdict) | Сообщи оркестратору, не `gh pr comment` |
| `pipeline-status({ pr_number })` | `python3 .opencode/scripts/pipeline-status.py` | Read-only oracle: статус фаз PR + NEXT action | Сообщи оркестратору, не bash-запуск скрипта |
| `spec-status({})` | `python3 .opencode/scripts/spec-status.py` | Read-only oracle: текущая фаза spec + NEXT action | Сообщи оркестратору, не bash-запуск скрипта |
| `memory-setup()` | `bash .opencode/scripts/setup-memory.sh` | Инициализация/синхронизация opencode-memory (clone + hook + reindex) | Сообщи оркестратору, не bash-запуск скрипта |
| `memory-doctor()` | — | Read-only диагностика памяти: ripgrep, Python src.memory, env vars, index (замена `memory-setup`) | Сообщи оркестратору, не raw bash |
| `memory-save()` | — | Commit + reindex opencode-memory после записи/редактирования (auto-setup: git init + hook если remote set) | Сообщи оркестратору, не raw bash |
| `memory-search({ query, category? })` | — | Гибридный поиск (ripgrep keyword + Python semantic) по opencode-memory | Сообщи оркестратору, не raw bash |
| `memory-list({ category? })` | — | Список категорий / файлов opencode-memory | Сообщи оркестратору, не raw bash |
| `memory-access({ path })` | — | Отметить memory-файл как прочитанный (bump `last_accessed`/`access_count`) | Сообщи оркестратору, не raw bash |
| `tunnel()` | `bash .opencode/scripts/tunnel.sh` | Cloudflare tunnel toggle (1-й вызов — start, 2-й — stop) | Сообщи оркестратору, не bash-запуск скрипта |
`gh pr comment*` остаётся в allow-list для обратной совместимости (ADR-019

View file

@ -31,7 +31,7 @@ RUN curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/c
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
RUN npm install -g opencode-ai repomix @mathew-cf/opencode-memory --unsafe-perm
RUN npm install -g opencode-ai repomix --unsafe-perm
WORKDIR /root/workspace

View file

@ -0,0 +1,63 @@
# ADR-045: Remove @mathew-cf/opencode-memory plugin + Rust artifacts
## Статус
Accepted (2026-07-26)
## Контекст
После миграции на единую гибридную систему памяти (5 TS tools, PR #101) + E2E
покрытия (PR #102) сохранилась двойная система:
- **Plugin `@mathew-cf/opencode-memory`** прописан в `opencode.json` блоке
`plugin` и устанавливается в `Dockerfile` через `npm install -g`. Но plugin
не вызывается из-за бага wrapper-пути (см. ADR-043, handoff pr-101) —
фактически мёртвый код, съедающий ~85 MB в image + cache.
- **Rust `rag-cli` binary** (внутри плагина) пишет `.rag/index.bin` (7.5 MB) +
`.rag/meta.json` (12 KB) в Rust-формате (`hidden_size`, `model_id`,
`file_hashes`). Python `src/memory/index.py` пишет `.rag/index.json` (210 MB,
4096-dim embeddings) + `.rag/meta.json` в Python-формате (`version`, `files`).
**Конфликт**: оба пишут `meta.json` по одному пути, разные форматы → Rust
перетирает Python → broken индекс при сосуществовании.
- **`setup-memory.sh`** (4.6 KB) — shell-скрипт инициализации, заменён на
auto-setup внутри `memory-save.ts` (git init + hook если remote set).
- **`memory-setup.ts`** (716 B) — TS tool-обёртка над `setup-memory.sh`,
заменён на `memory-doctor.ts` (read-only диагностика, не модифицирующий).
Cleanup нужно делать ПОСЛЕ подтверждения что TS tools работают (PR #101) и E2E
тесты проходят (PR #102) — иначе остались бы без памяти. Issue #97 (E2E green)
был blocker для #98.
## Решение
Удалить все артефакты старой системы за один PR (issue #98, фаза 3 эпика #94):
1. `opencode.json` — убрать блок `plugin` целиком. JSON остался валидным.
2. `Dockerfile` — убрать `@mathew-cf/opencode-memory` из `npm install -g`.
3. `.opencode/scripts/setup-memory.sh` — удалить (auto-setup в `memory-save.ts`).
4. `.opencode/tools/memory-setup.ts` — удалить (заменён на `memory-doctor.ts`).
5. Rust артефакты на хосте: `.rag/index.bin`, `.rag/meta.json` (Rust-формат),
`~/.cache/opencode/packages/@mathew-cf/opencode-memory@latest/` + sibling
`opencode-memory/` (оба 85 MB). НЕ трогать `.rag/index.json` (Python).
6. `AGENTS.md` + `SKILL.md` — обновить Tool Usage Policy: убрать
`memory-setup()`, добавить 5 kebab-case tools (`memory-doctor`, `memory-save`,
`memory-search`, `memory-list`, `memory-access`).
7. Тесты: инвертировать assertions `test_dockerfile.py` (плагин должен
ОТСУТСТВОВАТЬ), удалить тесты для `setup-memory.sh` и `memory-setup.ts`.
Имя tool-casing: **kebab-case** для локальных `.opencode/tools/*.ts` (runtime
registry = filename), confirmed ADR-009 + memory `tool-name-casing-kebab-vs-mcp-snake`.
MCP plugin tools были snake_case, но плагин удалён — больше не актуально.
## Альтернативы
- **Оставить plugin как fallback** — отклонено: plugin не вызывается из-за
wrapper-бага, Rust meta.json конфилктует с Python, ~170 MB мёртвого веса.
Двойная система = каша, двойной maintenance, риск что Rust перетрёт Python
индекс.
- **Удалить только Rust артефакты, оставить plugin в конфиге** — отклонено:
plugin без `npm install` = silent skip (opencode логирует warning), но
оставляет мёртвый блок в конфиге + вводит в заблуждение.
- **Поэтапный cleanup (несколько PR)** — отклонено: артефакты тесно связаны
(plugin → setup-memory.sh → memory-setup.ts → Rust binary), частичный cleanup
оставляет поломанное состояние.

View file

@ -0,0 +1,86 @@
---
pr: 103
title: Remove @mathew-cf/opencode-memory plugin + setup-memory.sh + Rust artifacts
---
## Что сделано
Cleanup двойной системы памяти после перехода на единую гибридную (5 TS tools
PR #101 + E2E PR #102). Удалено всё, что связано со старой plugin-based системой:
1. **`opencode.json`** — убран блок `plugin` с `@mathew-cf/opencode-memory`
(строки 3-10). JSON остался валидным.
2. **`Dockerfile:34`** — `@mathew-cf/opencode-memory` убран из `npm install -g`
(`opencode-ai repomix --unsafe-perm`).
3. **Удалены файлы**:
- `.opencode/scripts/setup-memory.sh` — заменён на auto-setup в `memory-save.ts`
- `.opencode/tools/memory-setup.ts` — заменён на `memory-doctor.ts` (read-only)
4. **Rust артефакты на хосте** (не в git):
- `~/.local/share/opencode/opencode-memory/.rag/index.bin` (7.5 MB, Rust binary)
- `~/.local/share/opencode/opencode-memory/.rag/meta.json` (12 KB, Rust формат
`hidden_size`/`model_id`/`file_hashes` — перетёр бы Python `version`/`files`)
- `~/.cache/opencode/packages/@mathew-cf/opencode-memory@latest/` (85 MB)
- `~/.cache/opencode/packages/@mathew-cf/opencode-memory/` (85 MB, sibling без
`@latest` — тот же плагин, удалён для полноты cleanup)
- Итого освобождено: ~177.5 MB
- Python `index.json` (210 MB, 4096-dim embeddings) НЕ тронут.
5. **`AGENTS.md`** (project) — таблица Tool Usage Policy обновлена: убран
`memory-setup()`, добавлены `memory-doctor`, `memory-save`, `memory-search`,
`memory-list`, `memory-access` (kebab-case для TS tools).
6. **`SKILL.md`** (`.opencode/skills/memory/`) — обновлены имена tools
(`memory_search``memory-search`), описание (плагин удалён, 5 TS tools),
default путь памяти (`/root/.local/share/opencode/opencode-memory`).
7. **Тесты** адаптированы под cleanup:
- `tests/test_dockerfile.py` — assertions инвертированы: плагин должен
ОТСУТСТВОВАТЬ в Dockerfile (issue #98). Regression-guards для `opencode-ai`
и `repomix` сохранены.
- Удалены `tests/test_setup_memory.py`, `tests/test_memory_setup_tool.py`,
`tests/test_memory_setup_tool.ts` — тесты для удалённых `setup-memory.sh`
и `memory-setup.ts`.
- `tests/test_commit_tool.py` — docstring обновлён (ссылка на `memory-setup.ts`
заменена на `spec-status.ts`).
## Почему
Двойная система (plugin + 5 TS tools) = каша. Plugin `@mathew-cf/opencode-memory`
не вызывался из-за бага wrapper-пути, Rust `rag-cli` binary конфликтовал с Python
`index.json` (разные форматы `meta.json` по одному пути). Cleanup ПОСЛЕ того как
5 TS tools работают (PR #101) и E2E тесты проходят (PR #102) — иначе остались бы
без памяти.
Часть эпика #94 (migration plan), фаза 3 (cleanup). Blocked by #97 (E2E green).
## Pending
- Глобальный `/root/.config/opencode/AGENTS.md` и
`/root/.config/opencode/skills/memory/SKILL.md` НЕ обновлены — Write tool
заблокирован для путей вне workspace (`FileSystem.writeFile` error). Это
ручная задача пользователя (копии тех же правок, что в project-файлах).
- `OPENAI_EMBEDDING_BATCH_DELAY` НЕ добавлен в `.env.example` — этот env var
НЕ существует в коде (`src/memory/embedder.py` использует только
`OPENAI_EMBEDDING_BATCH_SIZE` + `Retry-After` из 429-ответа). Добавление
мусорной переменной противоречит правилу «не добавлять мёртвый код». Issue #98
задача 8 ссылается на «аудит» — вероятно устаревшая/ошибочная находка.
- `~/.local/share/opencode/opencode-memory/.gitignore` — отсутствует. Это
локальный репозиторий памяти (отдельный git), `.gitignore` для `.rag/` там
не относится к этому PR — задача 5 issue #98, но не блокер (`.rag/` уже не
содержит Rust артефактов).
## Watch out
- **Кеш `@mathew-cf/opencode-memory/` (без `@latest`)** — кроме `@latest/`
существовал sibling-алиас `opencode-memory/` (85 MB, тот же плагин). Удалён
тоже — иначе кеш не полностью очищен. Issue #98 явно указывает только
`@latest/`, но критерий приёмки «Cache удалён» подразумевает полный cleanup.
- **meta.json** — Python `index.py` и Rust `rag-cli` пишут `meta.json` в ОДИН
путь (`<output>/.rag/meta.json`) но в РАЗНЫХ форматах. Rust: `{model_id,
hidden_size, num_chunks, file_hashes}`. Python: `{version, files:{rel:{sha256,
chunks}}}`. Текущий был Rust-формат → удалён; Python пересоздаст при следующем
reindex. Если снова поставить плагин — он перетрёт Python meta → broken.
- **Тест `test_memory_plugin_in_npm_install`** переименован в
`test_memory_plugin_removed_from_npm_install` (assertion инвертирован). Если
кто-то откатит cleanup — тест провалится с понятным сообщением.
- **Plugin auto-load** — после удаления блока `plugin` из `opencode.json`
плагин НЕ грузится при старте opencode (критерий #98 выполнен).
- **`memory-doctor` tool** — после cleanup НЕ репортит проблем с plugin/Rust
(он их и не проверял — проверяет ripgrep/Python/env/index, которые intact).

View file

@ -2,7 +2,7 @@
opencode-config — Docker-based AI coding assistant with persistent memory (opencode configuration). Runs in Docker via `docker-compose.yml` (dind + opencode services).
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица 10 tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-setup/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback). Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-doctor/memory-save/memory-search/memory-list/memory-access/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback; `memory-setup` REMOVED PR#103). Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
## Structure
@ -49,7 +49,6 @@ opencode-config/
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback) — PR#101
│ │ ├── memory-setup.ts # memory-setup tool wrapper (0 args, calls setup-memory.sh) — PR#36
│ │ ├── pipeline-status.ts # pipeline-status tool wrapper
│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
@ -61,10 +60,9 @@ 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 (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
│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools) — PR#40, PR#69 (plugin block REMOVED PR#103)
│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101
│ └── .gitignore # Ignores node_modules, etc.
├── docs/
@ -86,7 +84,7 @@ opencode-config/
│ ├── test_check_permissions.py # permissions-check.yml validator
│ ├── test_cli.py # src/memory/cli.py
│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) — PR#51
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix + @mathew-cf/opencode-memory) — PR#57
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103
│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38
│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38
│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
@ -97,8 +95,6 @@ opencode-config/
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
│ ├── 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_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only, full hybrid, OpenRouter fallback, deletion; @pytest.mark.skipif(not RUN_LIVE); subprocess-based: ripgrep + python3 -m src.memory) — PR#102
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
@ -116,7 +112,6 @@ 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, 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
@ -125,7 +120,7 @@ opencode-config/
├── uv.lock # Locked deps for Python project
├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + @mathew-cf/opencode-memory + cloudflared — PR#24, PR#34, PR#57, PR#71
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR)
├── app_data/
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36

View file

@ -135,7 +135,7 @@ def test_wrong_type():
def test_execute_uses_cwd_from_context():
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).
Like pipeline-status.ts / memory-setup.ts, the commit tool wrapper
Like pipeline-status.ts / spec-status.ts, the commit tool wrapper
propagates ``context.worktree`` as ``cwd`` to spawnSync.
"""
out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE])

View file

@ -1,12 +1,12 @@
"""Tests for Dockerfile npm install line.
Covers issue #56 acceptance criteria:
- ``Dockerfile`` installs ``@mathew-cf/opencode-memory`` alongside
``opencode-ai`` and ``repomix`` in the global npm install step, so the
memory plugin (``memory_save`` / ``memory_search`` / ``memory_list`` MCP
tools) is available inside the container.
- ``opencode-ai`` is still present (regression guard must not be dropped
when editing the install line).
Covers issue #98 acceptance criteria:
- ``Dockerfile`` no longer installs ``@mathew-cf/opencode-memory`` the
plugin was replaced by 5 TS tools (``.opencode/tools/memory-*.ts``) that
call ``python3 -m src.memory`` directly (PR #101). The npm package is
dead weight in the image and is removed from the global install step.
- ``opencode-ai`` and ``repomix`` remain (regression guard must not be
dropped when editing the install line).
Checks are raw-text grep assertions (no Docker build needed), mirroring the
belt-and-suspenders style of ``test_docker_compose.py``.
@ -17,8 +17,8 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DOCKERFILE = REPO_ROOT / "Dockerfile"
REQUIRED_PACKAGES = ("opencode-ai", "repomix", "@mathew-cf/opencode-memory")
MEMORY_PLUGIN = "@mathew-cf/opencode-memory"
REMOVED_PLUGIN = "@mathew-cf/opencode-memory"
KEPT_PACKAGES = ("opencode-ai", "repomix")
OPENCODE_AI = "opencode-ai"
@ -41,33 +41,33 @@ def test_dockerfile_exists():
assert DOCKERFILE.exists(), f"Dockerfile missing: {DOCKERFILE}"
# ── @mathew-cf/opencode-memory installed (issue #56 — the required plugin) ───
# ── @mathew-cf/opencode-memory removed (issue #98 — plugin replaced by TS tools)
def test_memory_plugin_in_npm_install():
"""Dockerfile installs ``@mathew-cf/opencode-memory`` via ``npm install -g``.
def test_memory_plugin_removed_from_npm_install():
"""Dockerfile does NOT install ``@mathew-cf/opencode-memory`` via ``npm install -g``.
Without the plugin the memory MCP tools (``memory_save`` /
``memory_search`` / ``memory_list``) are unavailable opencode silently
skips the uninstalled plugin referenced in opencode.json.
The plugin was replaced by 5 TS tools (PR #101) that call
``python3 -m src.memory`` directly the npm package is no longer needed
inside the container and is removed from the global install step.
"""
lines = _npm_install_lines(_dockerfile_text())
assert lines, "no `npm install -g` line found in Dockerfile"
assert any(MEMORY_PLUGIN in line for line in lines), (
f"Dockerfile must install {MEMORY_PLUGIN!r} via `npm install -g` — "
"memory plugin MCP tools depend on it"
assert not any(REMOVED_PLUGIN in line for line in lines), (
f"Dockerfile must NOT install {REMOVED_PLUGIN!r} via `npm install -g` — "
"plugin replaced by TS tools (PR #101, cleanup issue #98)"
)
def test_memory_plugin_in_npm_install_raw():
"""Raw text check: the Dockerfile contains ``@mathew-cf/opencode-memory``.
def test_memory_plugin_removed_from_npm_install_raw():
"""Raw text check: the Dockerfile does NOT contain ``@mathew-cf/opencode-memory``.
Belt-and-suspenders alongside the parsed check catches edge cases where
the line collector might miss a multi-line or differently-formatted
install statement.
Belt-and-suspenders alongside the parsed check catches any lingering
reference to the removed plugin (multi-line install, comment, etc.).
"""
assert MEMORY_PLUGIN in _dockerfile_text(), (
f"Dockerfile must contain {MEMORY_PLUGIN!r} so the memory plugin loads inside the container"
assert REMOVED_PLUGIN not in _dockerfile_text(), (
f"Dockerfile must NOT contain {REMOVED_PLUGIN!r} — plugin replaced by "
"TS tools (PR #101, cleanup issue #98)"
)
@ -77,7 +77,7 @@ def test_memory_plugin_in_npm_install_raw():
def test_opencode_ai_present():
"""Dockerfile still installs ``opencode-ai`` (regression guard).
Editing the npm install line to add the memory plugin must not
Removing the memory plugin from the npm install line must not
accidentally drop the primary ``opencode-ai`` package.
"""
lines = _npm_install_lines(_dockerfile_text())
@ -93,11 +93,11 @@ def test_opencode_ai_present_raw():
assert OPENCODE_AI in _dockerfile_text(), f"Dockerfile must still contain {OPENCODE_AI!r}"
# ── single npm install line holds all three packages ────────────────────────
# ── single npm install line holds the kept packages ──────────────────────────
def test_single_npm_install_line_has_all_packages():
"""One ``npm install -g`` line installs all three required packages.
def test_single_npm_install_line_has_kept_packages():
"""One ``npm install -g`` line installs all kept packages.
Guards against splitting the install across multiple RUN layers (which
would waste image layers and obscure which package is missing).
@ -106,7 +106,7 @@ def test_single_npm_install_line_has_all_packages():
install_lines = [line for line in lines if OPENCODE_AI in line]
assert install_lines, f"no `npm install -g` line with {OPENCODE_AI!r}"
main_line = install_lines[0]
for pkg in REQUIRED_PACKAGES:
for pkg in KEPT_PACKAGES:
assert pkg in main_line, (
f"package {pkg!r} missing from the opencode-ai npm install line: {main_line!r}"
)

View file

@ -1,104 +0,0 @@
"""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

@ -1,56 +0,0 @@
/**
* 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")
})
})

View file

@ -1,410 +0,0 @@
"""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 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. 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
``.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"
REPO_PYTHON = str(REPO_ROOT / ".venv" / "bin" / "python")
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,
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). ``wrapper`` sets
``MEMORY_WRAPPER_PATH`` so tests don't write to the real plugin dir.
"""
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)
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,
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), 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"
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), wrapper=tmp_path / "wrapper" / "rag.js").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), 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"],
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), 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), 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(
["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), 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), 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}"
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[str, str | None]:
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), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
snap1 = _snapshot()
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}"
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
# ── git insteadOf helper (GITHUB_TOKEN) ──────────────────────────────────────
def test_script_contains_git_insteadof() -> None:
"""setup-memory.sh contains `git config --global url.insteadOf` before clone."""
src = SCRIPT.read_text()
assert "git config --global url." in src, "missing git config --global url. call"
assert ".insteadOf https://github.com/" in src, "missing insteadOf directive"
# The insteadOf block must appear BEFORE the git clone line.
insteadof_pos = src.index(".insteadOf")
clone_pos = src.index("git clone --origin")
assert insteadof_pos < clone_pos, "insteadOf helper must precede git clone"
def test_script_contains_github_token_check() -> None:
"""setup-memory.sh checks GITHUB_TOKEN (guarded, not hard-fail if empty)."""
src = SCRIPT.read_text()
assert "GITHUB_TOKEN" in src, "missing GITHUB_TOKEN reference"
# Must be a conditional check (if [ -n ... ]), not an unconditional hard fail.
assert '-n "${GITHUB_TOKEN:-}"' in src or '-n "$GITHUB_TOKEN"' in src, (
"GITHUB_TOKEN must be a guarded check (if [ -n ... ]), not a hard requirement"
)
def test_insteadof_idempotent(tmp_path: Path) -> None:
"""Running the script N times does not duplicate the insteadOf entry.
`git config --global url.<prefix>.insteadOf` overwrites the value on each
call (idempotent by git semantics). We verify by invoking setup-memory.sh
twice with a fake GITHUB_TOKEN against a mock remote and checking that
`git config --global --get-all` returns exactly one match after each run.
"""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
# Use an isolated HOME so --global writes to a tmp gitconfig (no pollution).
home = tmp_path / "home"
home.mkdir()
env = {
**os.environ,
"OPENCODE_MEMORY_DIR": str(mem),
"OPENCODE_MEMORY_REMOTE": str(remote),
"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)
assert r1.returncode == 0, f"run1 failed: stdout={r1.stdout}\nstderr={r1.stderr}"
# Read the global gitconfig written by the script.
gitconfig = home / ".gitconfig"
assert gitconfig.exists(), f"gitconfig not created at {gitconfig}"
content1 = gitconfig.read_text()
assert "x-access-token:fake-token-for-test@github.com" in content1
assert "insteadOf" in content1
# Count insteadOf occurrences — should be exactly 1 (no duplication).
insteadof_count_1 = content1.count("insteadOf")
assert insteadof_count_1 == 1, f"expected 1 insteadOf after run1, got {insteadof_count_1}"
# Second run: existing repo → pull path (clone branch skipped). The helper
# is inside the clone branch, so it won't re-fire — but we assert the
# config is NOT duplicated regardless (single insteadOf entry preserved).
r2 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env)
assert r2.returncode == 0, f"run2 failed: stdout={r2.stdout}\nstderr={r2.stderr}"
content2 = gitconfig.read_text()
insteadof_count_2 = content2.count("insteadOf")
assert insteadof_count_2 == 1, (
f"expected 1 insteadOf after run2, got {insteadof_count_2} (duplicate?)"
)
def test_no_github_token_skips_insteadof(tmp_path: Path) -> None:
"""When GITHUB_TOKEN is unset, the insteadOf helper is skipped (no error)."""
remote = tmp_path / "remote.git"
_seed_remote(remote)
mem = tmp_path / "mem"
home = tmp_path / "home"
home.mkdir()
env = {
**os.environ,
"OPENCODE_MEMORY_DIR": str(mem),
"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}"
gitconfig = home / ".gitconfig"
if gitconfig.exists():
content = gitconfig.read_text()
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"]))