---
pr: 75
title: Rename to memory, OpenAI env, chunking, batching, dedup
---

## Что сделано
- `pyproject.toml`: `name = "second-brain"` → `name = "memory"`, добавлена секция `[project.scripts] rag = "src.memory.cli:main"`. `uv.lock` регенерирован (`uv lock`).
- `src/memory/cli.py`: `prog="rag"` → `prog="memory"`.
- `src/memory/embedder.py`: env нейминг `AI_PROVIDER_API_URL`/`AI_PROVIDER_API_KEY` → `OPENAI_BASE_URL`/`OPENAI_API_KEY` (OpenAI-совместимый). `API_URL.rstrip("/")` фиксит trailing-slash баг (`/v1//embeddings` → 404). `EMBEDDING_MODEL` из env `OPENAI_EMBEDDING_MODEL` (default `gemini-embedding-2-preview`). `BATCH_SIZE = int(os.environ.get("OPENAI_EMBEDDING_BATCH_SIZE", "2048"))` — `embed_texts` режет texts > BATCH_SIZE на батчи, конкатенирует результаты.
- `src/memory/index.py`: добавлена `_chunk_text(text, size, overlap) -> list[tuple[str, int]]` (graceful при size<=overlap → 1 chunk). `run_index` использует чанки (`MEMORY_CHUNK_SIZE` default 512, `MEMORY_CHUNK_OVERLAP` default 64 из env). `file_map` поля: `source`, `chunk_idx`, `offset`, `text`. Пустые файлы → 1 запись с пустым чанком (не дропаются).
- `src/memory/search.py`: дедуп по `source` в top-K — после sort по score, итерация с `seen` set, оставляет highest score per source, останавливается на K уникальных.
- `.env.example`: добавлен блок "OpenAI Embeddings (Memory CLI)" с 6 переменными (`OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_EMBEDDING_MODEL`, `OPENAI_EMBEDDING_BATCH_SIZE`, `MEMORY_CHUNK_SIZE`, `MEMORY_CHUNK_OVERLAP`). `AI_PROVIDER_BASE_URL`/`AI_PROVIDER_API_KEY` оставлены (LLM провайдер в opencode.json, отдельный concern).
- `tests/test_embedder.py`: env нейминг (`OPENAI_BASE_URL`), + 4 теста: `test_embed_texts_default_model`, `test_embed_texts_custom_model` (reload с env override), `test_embed_texts_trailing_slash` (URL без `//`), `test_embed_texts_batches` (3000 texts → 2 вызова [2048, 952]).
- `tests/test_index.py`: + 3 теста: `test_chunking_long_file` (1500 chars → 3+ records, chunk_idx/offset), `test_chunking_size_env_override` (`MEMORY_CHUNK_SIZE=256`), `test_chunking_skip_rag_dir`. Существующий `test_index_creates_json` обновлён (проверка `chunk_idx`/`offset`).
- `tests/test_search.py`: + `test_dedup_by_source` (2 чанка одного source → 1 в top-K, highest score).
- `tests/test_chunking.py` (новый): 7 edge cases `_chunk_text` — empty, 1 char, ровно size, size+1, unicode emoji, size<overlap, size==overlap.
- `tests/test_embedder_live.py` (новый): 2 live-теста (`@pytest.mark.skipif(not RUN_LIVE)`) — single + batch embed.
- `README.md:40`, `docs/project-map/README.md:69`: `second-brain` → `memory`. Project-map обновлён: `embedder.py` → `OPENAI_BASE_URL`, `index.py` → chunking, `search.py` → dedup, `cli.py` → `prog="memory"`, + entries для `test_embedder_live.py`, `test_chunking.py`.
- ADR-032 + этот handoff

## Почему
7 проблем в `src/memory/` (RAG CLI для opencode-memory plugin), чинятся этим PR:

1. **Нейминг-путаница**: `pyproject.toml` name=`second-brain`, модуль `src/memory/`, CLI `prog="rag"` — три разных имени. Унифицировано под `memory` (пакет) + `rag` script entry point (совместимость с setup-memory.sh wrapper).
2. **Env-нейминг нестандартен**: код читал `AI_PROVIDER_API_URL`, но `.env.example`/`opencode.json` используют `AI_PROVIDER_BASE_URL` — код не работал "из коробки". Перешли на OpenAI-стандарт `OPENAI_BASE_URL`/`OPENAI_API_KEY` (подтверждено openai-python docs).
3. **Trailing-slash бага**: `f"{API_URL}/embeddings"` без `.rstrip("/")` → при `OPENAI_BASE_URL=https://api.openai.com/v1/` URL становился `/v1//embeddings` → HTTP 404 (воспроизведено smoke-test'ом).
4. **Индекс 1-эмбеддинг-на-файл**: 1 embedding на весь файл — нет чанков. Для 74 файлов (1.2 MB) грубо, не находит фрагменты внутри длинных файлов. Добавлено чанкирование (default 512 chars, overlap 64).
5. **Нет batching**: все texts одним POST — при 3396 чанках риск упереться в OpenAI лимит (2048 input для text-embedding-3-small). Добавлен batching (default 2048).
6. **Нет дедупа в search**: 2 чанка одного файла в top-K засоряли выдачу одним файлом. Добавлен дедуп по source (highest score per source).
7. **Модель захардкожена**: `EMBEDDING_MODEL = "gemini-embedding-2-preview"` — нельзя сменить без правки кода. Вынесена в env `OPENAI_EMBEDDING_MODEL`.

PR #1 в серии из 2 (PR #2 — интеграция в плагин через setup-memory.sh wrapper).

## Pending
- PR #2: интеграция `memory` CLI в плагин `@mathew-cf/opencode-memory` через `setup-memory.sh` (замена Rust rag-cli на Python `memory index`/`memory search`). Требует: OPENAI_BASE_URL/OPENAI_API_KEY в `.env`, обновление wrapper в setup-memory.sh.
- `opencode.json:28` всё ещё использует `AI_PROVIDER_API_KEY` для LLM провайдера — отдельный concern (LLM, не embeddings), НЕ трогался в этом PR. Возможный future PR для OpenAI-стандартизации LLM env.
- Smoke-test (3396 чанков, 1.2 MB index.json) — требует реальный API key, не запускался в CI (только unit-тесты с моками). Запустить вручную перед merge: `OPENAI_BASE_URL=$AI_PROVIDER_BASE_URL OPENAI_API_KEY=$AI_PROVIDER_API_KEY uv run python -m src.memory index /root/.local/share/opencode/opencode-memory -o /tmp/.rag-test && ls /tmp/.rag-test/index.json`

## Watch out
- **`EMBEDDING_MODEL`/`BATCH_SIZE` — module-level, читаются при импорте.** Тесты с env override (custom model, trailing slash) делают `importlib.reload(embedder_mod)` и восстанавливают env после теста. Если未来的 код сделает `EMBEDDING_MODEL` mutable через функцию — паттерн reload сломается. Альтернатива (читать env в `embed_texts` каждый вызов) отклонена: лишний overhead на каждый вызов + несовместимость с существующим `API_URL` module-level паттерном.
- **Reload-тесты меняют module global** — `test_embed_texts_custom_model`/`test_embed_texts_trailing_slash` используют `monkeypatch.setenv` + `importlib.reload`. `monkeypatch` auto-restore env, но reload модуля НЕ auto-restore. Явный `reload` в конце каждого теста восстанавливает default state. Если тест упадёт ДО финального reload — следующий тест может получить stale module state. Mitigation: `monkeypatch.setenv` восстанавливает env, а финальный `reload` в `finally`-стиле (после assert) — нет. Если тест упал на assert — reload не выполнится. Риск минимальный (assert в конце), но для robustness можно обернуть в try/finally — НЕ сделано для простоты.
- **Chunking `size<=overlap` → 1 chunk (не infinite loop).** `_chunk_text` возвращает `[(text, 0)]` если `size <= overlap` (step = size-overlap <= 0 → `range(0, len, 0)` = infinite). Это graceful degradation, не ошибка. Тест `test_size_less_than_overlap`/`test_size_equals_overlap` покрывают.
- **`file_map` тип `dict[str, str | int]`** — `chunk_idx`/`offset` это `int`, `source`/`text` это `str`. mypy strict требует union. Существующий `dict[str, str]` расширен до `dict[str, str | int]`. JSON serialization сохраняет int как int (не string).
- **Empty files → 1 запись с пустым чанком.** `_chunk_text("")` возвращает `[]`, но `run_index` заменяет на `[("", 0)]` чтобы не дропать файл из индекса (поиск по empty embedding даст score 0, но файл остаётся в index для completeness). Альтернатива (дропать empty files) отклонена — меняет существующее поведение (раньше empty files попадали в индекс с 1 embedding).
- **`uv lock` обязателен после rename `pyproject.toml`** — `uv.lock:716` содержал `name = "second-brain"`. Без `uv lock` lockfile рассинхронизирован с pyproject. CI `uv sync` использует lockfile → несоответствие. `uv lock` регенерирован (confirmed: `Removed second-brain v0.1.0`, `Added memory v0.1.0`).
- **ADR number = sequential (032), НЕ PR number.** Проверить ADR naming в handoff до push (эволюция паттерна PR#26 docs-reviewer typo).