refactor(memory): rename to memory, OpenAI env, chunking, batching, dedup (#75)

* refactor(memory): rename package second-brain to memory

* refactor(memory): use OpenAI env naming and fix trailing slash

* feat(memory): add chunking with env-configurable size and overlap

* feat(memory): dedup search results by source in top-K

* test(memory): add chunking, batching, dedup, live tests

* docs(memory): update README and project map after rename

* docs(handoff): add handoff and ADR-032 for memory refactor

* docs(handoff): set PR number

* fix(ci): reduce index.py complexity to rank A

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-26 16:13:05 +03:00 committed by GitHub
parent 92d21a142c
commit 80a4be1d21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 492 additions and 67 deletions

View file

@ -2,6 +2,14 @@
AI_PROVIDER_BASE_URL=https://your-ai-provider.example.com/v1/ AI_PROVIDER_BASE_URL=https://your-ai-provider.example.com/v1/
AI_PROVIDER_API_KEY=your-api-key-here AI_PROVIDER_API_KEY=your-api-key-here
# OpenAI Embeddings (Memory CLI)
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=your-openai-api-key
OPENAI_EMBEDDING_MODEL=gemini-embedding-2-preview
OPENAI_EMBEDDING_BATCH_SIZE=2048
MEMORY_CHUNK_SIZE=512
MEMORY_CHUNK_OVERLAP=64
# OpenCode Server # OpenCode Server
OPENCODE_SERVER_PASSWORD=your-opencode-server-password OPENCODE_SERVER_PASSWORD=your-opencode-server-password

View file

@ -37,7 +37,7 @@ opencode # .opencode/ auto-discovered
- `app_data/workspaces/` — agent working directory - `app_data/workspaces/` — agent working directory
- `app_data/ssh/` — SSH keys (not in git) - `app_data/ssh/` — SSH keys (not in git)
- `app_data/opencode-memory/` — persistent memory (separate git repo) - `app_data/opencode-memory/` — persistent memory (separate git repo)
- `src/` — Python RAG CLI (second-brain) - `src/` — Python RAG CLI (memory)
- `docs/` — handoffs, decisions (ADRs), project map - `docs/` — handoffs, decisions (ADRs), project map
- `.github/workflows/` — CI workflows (ubuntu-latest) - `.github/workflows/` — CI workflows (ubuntu-latest)

View file

@ -0,0 +1,23 @@
# ADR-032: Rename to memory, OpenAI env, chunking, batching, dedup (PR #75)
## Статус
Accepted (2026-07-26)
## Контекст
Python-пакет `src/memory/` (RAG CLI для opencode-memory plugin) имел 7 проблем: нейминг-путаница (3 разных имени для одного пакета), нестандартный env-нейминг (`AI_PROVIDER_API_URL``AI_PROVIDER_BASE_URL` из `.env.example`/`opencode.json`), реальная 404-бага из-за trailing slash, грубый индекс (1 эмбеддинг на файл без чанков), отсутствие batching (риск упереться в OpenAI лимит input), отсутствие дедупа в search (засорение top-K одним файлом), захардкоженная модель эмбеддингов.
PR #1 в серии из 2 — этот PR чинит CLI, PR #2 интегрирует CLI в плагин через setup-memory.sh.
## Решение
1. **Нейминг**: `pyproject.toml` name=`second-brain``memory`, `[project.scripts] rag = "src.memory.cli:main"` (script entry point, совместимость с wrapper). `cli.py` `prog="rag"``prog="memory"`. Модуль `src/memory/` НЕ переименован (src-layout, `packages=["src"]`).
2. **Env-нейминг OpenAI-стандарт**: `OPENAI_BASE_URL`/`OPENAI_API_KEY` (вместо `AI_PROVIDER_API_URL`/`AI_PROVIDER_API_KEY`). `API_URL.rstrip("/")` фиксит trailing-slash. `EMBEDDING_MODEL` из env `OPENAI_EMBEDDING_MODEL` (default `gemini-embedding-2-preview`).
3. **Chunking**: `_chunk_text(text, size, overlap)` в `index.py`, параметры из env `MEMORY_CHUNK_SIZE` (512)/`MEMORY_CHUNK_OVERLAP` (64). `file_map` с `chunk_idx`/`offset`/`text`. Graceful при `size<=overlap` (1 chunk, не infinite loop).
4. **Batching**: `BATCH_SIZE = int(os.environ.get("OPENAI_EMBEDDING_BATCH_SIZE", "2048"))` в `embedder.py`. `embed_texts` режет texts > BATCH_SIZE, конкатенирует результаты. Default 2048 = OpenAI лимит для text-embedding-3-small.
5. **Дедуп**: `search.py` после sort по score итерирует с `seen` set, оставляет highest score per source, останавливается на K уникальных source'ов.
6. **Тесты**: `test_chunking.py` (7 edge cases), `test_embedder_live.py` (2 live, skip без RUN_LIVE), + 4 embedder теста (default/custom model, trailing slash, batches), + 3 index теста (long file, env override, .rag skip), + 1 search тест (dedup).
## Альтернативы
- **Полный rename `src/memory/` → `src/rag/`** — отклонено: src-layout `packages=["src"]` в pyproject, rename модуля требует обновления всех imports (cli, embedder, index, search, tests, __main__). Script entry point `rag = "src.memory.cli:main"` даёт исполняемый `rag` без rename модуля. Module name `memory` (что package делает) + script name `rag` (как пользователь вызывает) — разумное разделение.
- **Читать `OPENAI_BASE_URL`/`EMBEDDING_MODEL`/`BATCH_SIZE` в `embed_texts` каждый вызов** (вместо module-level) — отклонено: лишний overhead на каждый вызов (env lookup + int parse), несовместимость с существующим `API_URL` module-level паттерном. Module-level + `importlib.reload` в тестах — trade-off: простой код, хрупкие reload-тесты.
- **Дропать empty files из индекса** (вместо 1 записи с пустым чанком) — отклонено: меняет существующее поведение (раньше empty files попадали в индекс с 1 embedding). Empty embedding даёт score 0, файл остаётся в index для completeness.
- **Параметризовать `_chunk_text` через argparse вместо env** — отклонено: env-параметры позволяют настраивать без правки CLI invocation, совместимо с setup-memory.sh wrapper (env передаётся через process env, не флаги).

View file

@ -0,0 +1,46 @@
---
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).

View file

@ -66,14 +66,14 @@ opencode-config/
│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md) │ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md)
│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md) │ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md)
│ └── project-map/ # This file — structure snapshot │ └── project-map/ # This file — structure snapshot
├── src/ # Python RAG CLI (second-brain) — PR#17 ├── src/ # Python RAG CLI (memory) — PR#17
│ └── memory/ │ └── memory/
│ ├── __init__.py │ ├── __init__.py
│ ├── __main__.py # Entry point for `python -m memory` │ ├── __main__.py # Entry point for `python -m memory`
│ ├── cli.py # CLI commands │ ├── cli.py # CLI commands (prog="memory")
│ ├── embedder.py # Embedding via AI_PROVIDER_API_URL (env-only) │ ├── embedder.py # Embedding via OPENAI_BASE_URL (env-only, OpenAI-compatible)
│ ├── index.py # Indexing │ ├── index.py # Indexing with chunking (MEMORY_CHUNK_SIZE/OVERLAP env)
│ └── search.py # Search │ └── search.py # Search with dedup by source in top-K
├── tests/ # pytest + TS/MJS test suite — PR#17 ├── tests/ # pytest + TS/MJS test suite — PR#17
│ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes; relative import inlining via inlineShared()) — PR#38, PR#65 │ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes; relative import inlining via inlineShared()) — PR#38, PR#65
│ ├── test_agent_frontmatter.py # Agent frontmatter validators (no top-level doom_loop, permission.doom_loop present, steps:150) — PR#49, PR#69 │ ├── test_agent_frontmatter.py # Agent frontmatter validators (no top-level doom_loop, permission.doom_loop present, steps:150) — PR#49, PR#69
@ -88,8 +88,10 @@ opencode-config/
│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65 │ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65 │ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65 │ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
│ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL) │ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL)
│ ├── test_index.py # src/memory/index.py │ ├── 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.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_setup_tool.ts # TS wrapper test (mjs loader) — PR#36
│ ├── 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

View file

@ -3,7 +3,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "second-brain" name = "memory"
version = "0.1.0" version = "0.1.0"
description = "Docker-based AI coding assistant with persistent memory" description = "Docker-based AI coding assistant with persistent memory"
readme = "README.md" readme = "README.md"
@ -39,6 +39,9 @@ dev = [
"pre-commit>=3.7", "pre-commit>=3.7",
] ]
[project.scripts]
rag = "src.memory.cli:main"
[project.urls] [project.urls]
Homepage = "https://github.com/slaid098/opencode-config" Homepage = "https://github.com/slaid098/opencode-config"
Repository = "https://github.com/slaid098/opencode-config" Repository = "https://github.com/slaid098/opencode-config"

View file

@ -5,7 +5,7 @@ from src.memory.search import run_search
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(prog="rag") parser = argparse.ArgumentParser(prog="memory")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
search_p = sub.add_parser("search") search_p = sub.add_parser("search")

View file

@ -3,11 +3,13 @@ import os
import httpx import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
API_URL = os.environ.get("AI_PROVIDER_API_URL") API_URL = os.environ.get("OPENAI_BASE_URL")
if not API_URL: if not API_URL:
raise RuntimeError("AI_PROVIDER_API_URL env var not set") raise RuntimeError("OPENAI_BASE_URL env var not set")
API_KEY = os.environ.get("AI_PROVIDER_API_KEY", "") API_URL = API_URL.rstrip("/")
EMBEDDING_MODEL = "gemini-embedding-2-preview" API_KEY = os.environ.get("OPENAI_API_KEY", "")
EMBEDDING_MODEL = os.environ.get("OPENAI_EMBEDDING_MODEL", "gemini-embedding-2-preview")
BATCH_SIZE = int(os.environ.get("OPENAI_EMBEDDING_BATCH_SIZE", "2048"))
def _is_retryable(exc: BaseException) -> bool: def _is_retryable(exc: BaseException) -> bool:
@ -42,7 +44,15 @@ def embed_texts(texts: list[str]) -> list[list[float]]:
url = f"{API_URL}/embeddings" url = f"{API_URL}/embeddings"
headers = {"Authorization": f"Bearer {API_KEY}"} headers = {"Authorization": f"Bearer {API_KEY}"}
payload: dict[str, str | list[str]] = {"model": EMBEDDING_MODEL, "input": texts}
with httpx.Client(timeout=120.0) as client: with httpx.Client(timeout=120.0) as client:
if len(texts) <= BATCH_SIZE:
payload: dict[str, str | list[str]] = {"model": EMBEDDING_MODEL, "input": texts}
return _call_embedding_api(client, url, headers, payload) return _call_embedding_api(client, url, headers, payload)
results: list[list[float]] = []
for i in range(0, len(texts), BATCH_SIZE):
batch = texts[i : i + BATCH_SIZE]
payload = {"model": EMBEDDING_MODEL, "input": batch}
results.extend(_call_embedding_api(client, url, headers, payload))
return results

View file

@ -1,5 +1,6 @@
import argparse import argparse
import json import json
import os
from pathlib import Path from pathlib import Path
from src.memory.embedder import embed_texts from src.memory.embedder import embed_texts
@ -15,30 +16,53 @@ def _extract_text(content: str) -> str:
return content.strip() return content.strip()
def _chunk_text(text: str, size: int, overlap: int) -> list[tuple[str, int]]:
if not text or size <= overlap or len(text) <= size:
return [(text, 0)] if text else []
step = size - overlap
return [(text[i : i + size], i) for i in range(0, len(text), step)]
def _file_entry(rel: Path, chunk_idx: int, offset: int, chunk: str) -> dict[str, str | int]:
return {
"source": str(rel),
"chunk_idx": chunk_idx,
"offset": offset,
"text": chunk[:500],
}
def _build_file_map(
memory_dir: Path, md_files: list[Path], chunk_size: int, chunk_overlap: int
) -> tuple[list[str], list[dict[str, str | int]]]:
texts: list[str] = []
file_map: list[dict[str, str | int]] = []
for fpath in md_files:
rel = fpath.relative_to(memory_dir)
content = fpath.read_text(encoding="utf-8")
text = _extract_text(content)
chunks = _chunk_text(text, chunk_size, chunk_overlap) or [("", 0)]
for chunk_idx, (chunk, offset) in enumerate(chunks):
texts.append(chunk)
file_map.append(_file_entry(rel, chunk_idx, offset, chunk))
return texts, file_map
def run_index(args: argparse.Namespace) -> None: def run_index(args: argparse.Namespace) -> None:
memory_dir = Path(args.memory_dir) memory_dir = Path(args.memory_dir)
output_dir = Path(args.output) output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
md_files = sorted(memory_dir.rglob("*.md")) md_files = [f for f in sorted(memory_dir.rglob("*.md")) if ".rag" not in f.parts]
md_files = [f for f in md_files if ".rag" not in f.parts]
if not md_files: if not md_files:
print("No .md files found") print("No .md files found")
return return
texts: list[str] = [] chunk_size = int(os.environ.get("MEMORY_CHUNK_SIZE", "512"))
file_map: list[dict[str, str]] = [] chunk_overlap = int(os.environ.get("MEMORY_CHUNK_OVERLAP", "64"))
for fpath in md_files:
rel = fpath.relative_to(memory_dir)
content = fpath.read_text(encoding="utf-8")
text = _extract_text(content)
texts.append(text)
file_map.append({"source": str(rel), "text": text[:500]})
texts, file_map = _build_file_map(memory_dir, md_files, chunk_size, chunk_overlap)
embeddings = embed_texts(texts) embeddings = embed_texts(texts)
index = { index = {
"files": [{**fm, "embedding": emb} for fm, emb in zip(file_map, embeddings, strict=False)], "files": [{**fm, "embedding": emb} for fm, emb in zip(file_map, embeddings, strict=False)],
} }

View file

@ -37,6 +37,16 @@ def run_search(args: argparse.Namespace) -> None:
results.append({"source": f["source"], "score": sim, "text": f["text"]}) results.append({"source": f["source"], "score": sim, "text": f["text"]})
results.sort(key=lambda r: r["score"], reverse=True) results.sort(key=lambda r: r["score"], reverse=True)
top = results[: args.k]
seen: set[str] = set()
top: list[dict[str, str | float]] = []
for r in results:
src = str(r["source"])
if src in seen:
continue
seen.add(src)
top.append(r)
if len(top) >= args.k:
break
print(json.dumps(top, ensure_ascii=False)) print(json.dumps(top, ensure_ascii=False))

47
tests/test_chunking.py Normal file
View file

@ -0,0 +1,47 @@
import os
os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
from src.memory.index import _chunk_text
class TestChunkText:
def test_empty(self) -> None:
assert _chunk_text("", 512, 64) == []
def test_single_char(self) -> None:
assert _chunk_text("x", 512, 64) == [("x", 0)]
def test_exactly_size(self) -> None:
text = "a" * 512
assert _chunk_text(text, 512, 64) == [(text, 0)]
def test_size_plus_one(self) -> None:
text = "a" * 513
chunks = _chunk_text(text, 512, 64)
assert len(chunks) == 2
assert chunks[0] == (text[:512], 0)
assert chunks[1][1] == 512 - 64
assert chunks[1][0] == text[448 : 448 + 512]
def test_unicode_emoji(self) -> None:
text = "😀" * 100
chunks = _chunk_text(text, 10, 2)
assert len(chunks) >= 2
offsets = [off for _, off in chunks]
assert offsets == sorted(offsets)
assert offsets[0] == 0
for chunk, _ in chunks:
assert chunk in text
def test_size_less_than_overlap(self) -> None:
text = "a" * 100
chunks = _chunk_text(text, 10, 20)
assert len(chunks) == 1
assert chunks[0] == (text, 0)
def test_size_equals_overlap(self) -> None:
text = "a" * 100
chunks = _chunk_text(text, 10, 10)
assert len(chunks) == 1
assert chunks[0] == (text, 0)

View file

@ -1,12 +1,14 @@
import importlib
import os import os
os.environ.setdefault("AI_PROVIDER_API_URL", "http://test/v1") os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
from typing import NoReturn from typing import NoReturn
from unittest.mock import patch from unittest.mock import patch
import httpx import httpx
import pytest import pytest
import src.memory.embedder as embedder_mod
from src.memory.embedder import embed_texts from src.memory.embedder import embed_texts
@ -60,3 +62,109 @@ def test_embed_texts_api_error() -> None:
with patch.object(httpx.Client, "post", mock_post), pytest.raises(httpx.HTTPStatusError): with patch.object(httpx.Client, "post", mock_post), pytest.raises(httpx.HTTPStatusError):
embed_texts(["test"]) embed_texts(["test"])
def test_embed_texts_default_model() -> None:
captured: dict[str, str | list[str]] = {}
class FakeResponse:
status_code = 200
def json(self):
return {"data": [{"embedding": [0.1], "index": 0}]}
def raise_for_status(self) -> None:
pass
def mock_post(self, url, **kwargs):
captured.update(kwargs["json"])
return FakeResponse()
with patch.object(httpx.Client, "post", mock_post):
embed_texts(["text"])
assert captured["model"] == "gemini-embedding-2-preview"
def test_embed_texts_custom_model(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
importlib.reload(embedder_mod)
captured: dict[str, str | list[str]] = {}
class FakeResponse:
status_code = 200
def json(self):
return {"data": [{"embedding": [0.1], "index": 0}]}
def raise_for_status(self) -> None:
pass
def mock_post(self, url, **kwargs):
captured.update(kwargs["json"])
return FakeResponse()
with patch.object(httpx.Client, "post", mock_post):
embedder_mod.embed_texts(["text"])
assert captured["model"] == "text-embedding-3-small"
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
importlib.reload(embedder_mod)
def test_embed_texts_trailing_slash(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.test.com/v1/")
importlib.reload(embedder_mod)
captured_url: dict[str, str] = {}
class FakeResponse:
status_code = 200
def json(self):
return {"data": [{"embedding": [0.1], "index": 0}]}
def raise_for_status(self) -> None:
pass
def mock_post(self, url, **kwargs):
captured_url["url"] = url
return FakeResponse()
with patch.object(httpx.Client, "post", mock_post):
embedder_mod.embed_texts(["text"])
assert captured_url["url"] == "https://api.test.com/v1/embeddings"
monkeypatch.setenv("OPENAI_BASE_URL", "http://test/v1")
importlib.reload(embedder_mod)
def test_embed_texts_batches() -> None:
calls: list[int] = []
class FakeResponse:
def __init__(self, count: int) -> None:
self.count = count
status_code = 200
def json(self):
return {"data": [{"embedding": [0.1], "index": i} for i in range(self.count)]}
def raise_for_status(self) -> None:
pass
def mock_post(self, url, **kwargs):
count = len(kwargs["json"]["input"])
calls.append(count)
return FakeResponse(count)
with patch.object(httpx.Client, "post", mock_post):
result = embed_texts(["text"] * 3000)
assert len(calls) == 2
assert calls == [2048, 952]
assert len(result) == 3000

View file

@ -0,0 +1,21 @@
import os
import pytest
os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
from src.memory.embedder import embed_texts
@pytest.mark.skipif(not os.environ.get("RUN_LIVE"), reason="needs RUN_LIVE=1")
def test_live_embed_single() -> None:
r = embed_texts(["hello world"])
assert len(r) == 1
assert len(r[0]) > 100
@pytest.mark.skipif(not os.environ.get("RUN_LIVE"), reason="needs RUN_LIVE=1")
def test_live_embed_batch() -> None:
r = embed_texts(["text one", "text two", "text three"])
assert len(r) == 3
assert all(len(emb) > 100 for emb in r)

View file

@ -1,8 +1,13 @@
import json import json
import os
from argparse import Namespace from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
from src.memory.index import _extract_text, run_index from src.memory.index import _extract_text, run_index
@ -39,6 +44,8 @@ class TestRunIndex:
assert len(index["files"]) == 1 assert len(index["files"]) == 1
assert index["files"][0]["source"] == "test.md" assert index["files"][0]["source"] == "test.md"
assert index["files"][0]["embedding"] == [0.1, 0.2, 0.3] assert index["files"][0]["embedding"] == [0.1, 0.2, 0.3]
assert index["files"][0]["chunk_idx"] == 0
assert index["files"][0]["offset"] == 0
def test_no_md_files(self, tmp_path: Path, capsys) -> None: def test_no_md_files(self, tmp_path: Path, capsys) -> None:
memory_dir = tmp_path / "empty" memory_dir = tmp_path / "empty"
@ -50,3 +57,74 @@ class TestRunIndex:
captured = capsys.readouterr() captured = capsys.readouterr()
assert "No .md files found" in captured.out assert "No .md files found" in captured.out
def test_chunking_long_file(self, tmp_path: Path) -> None:
memory_dir = tmp_path / "memory"
index_dir = tmp_path / ".rag"
memory_dir.mkdir(parents=True)
long_body = "a" * 1500
(memory_dir / "long.md").write_text(long_body)
def fake_embed_texts(texts):
return [[0.1, 0.2, 0.3]] * len(texts)
with patch("src.memory.index.embed_texts", fake_embed_texts):
args = Namespace(memory_dir=str(memory_dir), output=str(index_dir))
run_index(args)
index = json.loads((index_dir / "index.json").read_text())
assert len(index["files"]) >= 3
chunk_idxs = [f["chunk_idx"] for f in index["files"]]
assert chunk_idxs == sorted(chunk_idxs)
assert chunk_idxs[0] == 0
assert index["files"][0]["offset"] == 0
offsets = [f["offset"] for f in index["files"]]
assert offsets == sorted(offsets)
def test_chunking_size_env_override(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
memory_dir = tmp_path / "memory"
index_dir = tmp_path / ".rag"
memory_dir.mkdir(parents=True)
monkeypatch.setenv("MEMORY_CHUNK_SIZE", "256")
monkeypatch.setenv("MEMORY_CHUNK_OVERLAP", "0")
(memory_dir / "f.md").write_text("a" * 600)
def fake_embed_texts(texts):
return [[0.1]] * len(texts)
with patch("src.memory.index.embed_texts", fake_embed_texts):
args = Namespace(memory_dir=str(memory_dir), output=str(index_dir))
run_index(args)
index = json.loads((index_dir / "index.json").read_text())
assert len(index["files"]) == 3
for i, f in enumerate(index["files"]):
assert f["chunk_idx"] == i
assert f["offset"] == i * 256
def test_chunking_skip_rag_dir(self, tmp_path: Path) -> None:
memory_dir = tmp_path / "memory"
index_dir = memory_dir / ".rag"
memory_dir.mkdir(parents=True)
(memory_dir / "real.md").write_text("real content")
(index_dir).mkdir()
(index_dir / "index.json").write_text('{"files": []}')
(index_dir / "ignore.md").write_text("should be ignored")
def fake_embed_texts(texts):
return [[0.1]] * len(texts)
output_dir = tmp_path / "out"
with patch("src.memory.index.embed_texts", fake_embed_texts):
args = Namespace(memory_dir=str(memory_dir), output=str(output_dir))
run_index(args)
index = json.loads((output_dir / "index.json").read_text())
sources = [f["source"] for f in index["files"]]
assert "real.md" in sources
assert all(".rag" not in s for s in sources)

View file

@ -1,10 +1,14 @@
import json import json
import os
from argparse import Namespace from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import numpy as np import numpy as np
import pytest import pytest
os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
import src.memory.search as search_mod import src.memory.search as search_mod
from src.memory.search import _cosine_sim from src.memory.search import _cosine_sim
@ -56,3 +60,44 @@ class TestSearchOutput:
with patch.object(search_mod, "embed_texts", fake_embed_texts): with patch.object(search_mod, "embed_texts", fake_embed_texts):
args = Namespace(index_dir=str(index_dir), query="hello", k=5, json=True) args = Namespace(index_dir=str(index_dir), query="hello", k=5, json=True)
search_mod.run_search(args) search_mod.run_search(args)
def test_dedup_by_source(self, tmp_path: Path, capsys) -> None:
index_dir = tmp_path / ".rag"
index_dir.mkdir()
index = {
"files": [
{
"source": "doc.md",
"text": "chunk 0",
"embedding": [1.0, 0.0, 0.0],
},
{
"source": "doc.md",
"text": "chunk 1",
"embedding": [0.9, 0.1, 0.0],
},
{
"source": "other.md",
"text": "chunk 0",
"embedding": [0.5, 0.5, 0.0],
},
],
}
(index_dir / "index.json").write_text(json.dumps(index))
def fake_embed_texts(texts):
return [[1.0, 0.0, 0.0]]
with patch.object(search_mod, "embed_texts", fake_embed_texts):
args = Namespace(index_dir=str(index_dir), query="q", k=5, json=True)
search_mod.run_search(args)
captured = capsys.readouterr()
result = json.loads(captured.out)
assert len(result) == 2
sources = [r["source"] for r in result]
assert "doc.md" in sources
assert "other.md" in sources
assert sources.count("doc.md") == 1
doc_entry = next(r for r in result if r["source"] == "doc.md")
assert doc_entry["text"] == "chunk 0"

72
uv.lock generated
View file

@ -373,6 +373,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" },
] ]
[[package]]
name = "memory"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "numpy" },
{ name = "tenacity" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
{ name = "xenon" },
]
[package.metadata]
requires-dist = [
{ name = "httpx" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "numpy" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
{ name = "tenacity" },
{ name = "xenon", marker = "extra == 'dev'", specifier = ">=0.9" },
]
provides-extras = ["dev"]
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "2.3.0" version = "2.3.0"
@ -712,42 +748,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" },
] ]
[[package]]
name = "second-brain"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "numpy" },
{ name = "tenacity" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
{ name = "xenon" },
]
[package.metadata]
requires-dist = [
{ name = "httpx" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "numpy" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
{ name = "tenacity" },
{ name = "xenon", marker = "extra == 'dev'", specifier = ">=0.9" },
]
provides-extras = ["dev"]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"