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", "$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@mathew-cf/opencode-memory",
{
"memoryDir": "{env:OPENCODE_MEMORY_DIR}"
}
]
],
"skills": { "skills": {
"paths": [ "paths": [
".opencode/skills" ".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) # File Memory (opencode-memory)
Графовая память (Graphiti/FalkorDB) удалена — была нестабильна и забагована. Графовая память (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-search({ query, category? })` | Гибридный поиск (ripgrep keyword + Python semantic) |
| `memory_list(category?)` | Список категорий / файлов | | `memory-list({ category? })` | Список категорий / файлов |
| `memory_save()` | Commit + re-index после записи/редактирования | | `memory-save()` | Commit + reindex после записи/редактирования (auto-setup: git init + hook если `OPENCODE_MEMORY_REMOTE` set) |
| `memory_access(path)` | Отметить файл как прочитанный | | `memory-access({ path })` | Отметить файл как прочитанный (bump `last_accessed`/`access_count`) |
| `memory-setup()` | Проверить статус бэкендов | | `memory-doctor()` | Read-only диагностика: ripgrep, Python `src.memory`, env vars, index |
## Категории ## Категории
@ -24,7 +24,7 @@ description: Инструкция по работе с файловой памя
## Как работать ## Как работать
1. **Перед началом работы**`memory_search` по теме 1. **Перед началом работы**`memory-search` по теме
2. **В процессе** — сохранять находки сразу (контекст свежий) 2. **В процессе** — сохранять находки сразу (контекст свежий)
3. **В конце сессии** — retrospective: что узнал → сохранить, что было в памяти → обновить, чего не хватало → создать 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 {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` | | `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-запуск скрипта | | `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-запуск скрипта | | `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-запуск скрипта | | `tunnel()` | `bash .opencode/scripts/tunnel.sh` | Cloudflare tunnel toggle (1-й вызов — start, 2-й — stop) | Сообщи оркестратору, не bash-запуск скрипта |
`gh pr comment*` остаётся в allow-list для обратной совместимости (ADR-019 `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_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 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 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). 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 ## 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-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-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-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 │ │ ├── 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-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 │ │ ├── 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 │ │ ├── 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) │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 (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 │ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101
│ └── .gitignore # Ignores node_modules, etc. │ └── .gitignore # Ignores node_modules, etc.
├── docs/ ├── docs/
@ -86,7 +84,7 @@ opencode-config/
│ ├── test_check_permissions.py # permissions-check.yml validator │ ├── test_check_permissions.py # permissions-check.yml validator
│ ├── test_cli.py # src/memory/cli.py │ ├── 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_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.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_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 │ ├── 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_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip) │ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap) │ ├── 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_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.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 │ ├── 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.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_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
│ ├── 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, JS wrapper gen/backup) — PR#36, PR#77
│ ├── 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
│ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65 │ ├── 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 ├── uv.lock # Locked deps for Python project
├── .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, port 4096 on 0.0.0.0 — PR#24, PR#51 ├── 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) ├── .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 │ ├── 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(): def test_execute_uses_cwd_from_context():
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern). """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. propagates ``context.worktree`` as ``cwd`` to spawnSync.
""" """
out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE]) out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE])

View file

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