Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,22 @@ query → BM25 (FTS5 verbatim) + HNSW (distilled embedding)

```toml
[distill]
model = "claude-haiku-4-5-20251001" # 蒸留に使うモデル
provider = "claude" # 蒸留 backend: "claude" | "openai"(既定 "claude")
model = "claude-haiku-4-5-20251001" # 蒸留に使うモデル(provider 共通)
batch_limit = 20 # hook 1回あたりの蒸留上限
```

ローカル LLM(OpenAI 互換: Ollama / LM Studio / llama.cpp-server / vLLM)で蒸留する場合は `provider = "openai"` と `base_url` を指定する(新規依存なし・API キー不要=`Authorization` ヘッダーは送らないローカル専用):

```toml
[distill]
provider = "openai"
model = "qwen2.5:7b"
base_url = "http://localhost:11434/v1" # Ollama(LM Studio は :1234/v1)
```

`provider = "openai"` のとき `base_url` は必須。未設定/空なら警告して `claude` にフォールバックする。`provider = "claude"`(既定)では `base_url` は無視され、従来どおり `claude --print` で蒸留する。実装は `llm.py` の `DistillBackend` + `call_claude` dispatcher(`_call_openai` は stdlib `urllib`、`response_format=json_object` + プロンプト内スキーマ + `_validate_palace` 検証 + 1 回リトライ)。

## CLI Commands

```bash
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ After `loci init` (or `loci hook install`), everything runs automatically:

```toml
[distill]
provider = "claude" # Distillation backend: "claude" | "openai" (default "claude")
model = "claude-haiku-4-5" # Model for distillation (default)
batch_limit = 20 # Max distillations per hook run
min_chars = 100 # Skip distillation for exchanges shorter than this
Expand All @@ -155,6 +156,20 @@ min_chars = 50 # Skip indexing exchanges shorter than th

There are two `min_chars` settings: `[index] min_chars` controls what gets indexed at all, while `[distill] min_chars` further skips distillation (the LLM cost) for short exchanges that were already indexed.

### Distilling with a local LLM

Distillation is a small per-exchange structured-extraction task, so a local model is usually good enough. Any OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp-server, vLLM) works by setting `provider = "openai"` and `base_url` — no new dependencies, no API key (the `Authorization` header is never sent, so this is local-only):

```toml
[distill]
provider = "openai"
model = "qwen2.5:7b"
base_url = "http://localhost:11434/v1" # Ollama
# base_url = "http://localhost:1234/v1" # LM Studio
```

`base_url` is required when `provider = "openai"`; if it is missing or empty the provider falls back to `claude` with a warning. With `provider = "claude"` (the default), `base_url` is ignored and distillation runs through `claude --print` as before.

## Acknowledgments

The palace object model, room-based topic grouping, and BM25+HNSW fusion search are based on:
Expand Down
10 changes: 9 additions & 1 deletion src/codeatrium/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,16 @@ def init(
"# Codeatrium configuration\n"
"\n"
"[distill]\n"
'# provider = "claude" # 蒸留 LLM: "claude" | "openai"(既定 "claude")\n'
'# model = "claude-haiku-4-5-20251001"\n'
"# batch_limit = 20\n"
"# min_chars = 100 # この文字数未満の exchange は蒸留スキップ\n"
"#\n"
"# ローカル LLM(OpenAI 互換)で蒸留する場合:\n"
'# provider = "openai"\n'
'# model = "qwen2.5:7b"\n'
'# base_url = "http://localhost:11434/v1" # Ollama\n'
'# # base_url = "http://localhost:1234/v1" # LM Studio\n'
"\n"
"[index]\n"
"# min_chars = 50 # trivial フィルタ閾値(文字数)\n"
Expand Down Expand Up @@ -225,6 +232,7 @@ def init(
try:
from codeatrium.config import load_config
from codeatrium.distiller import distill_all
from codeatrium.llm import DistillBackend

cfg = load_config(root)
typer.echo("Running distillation...")
Expand All @@ -239,7 +247,7 @@ def _on_progress(

count, err_count = distill_all(
db,
model=cfg.distill_model,
backend=DistillBackend.from_config(cfg),
on_progress=_on_progress,
project_root=str(root),
distill_min_chars=cfg.distill_min_chars,
Expand Down
5 changes: 3 additions & 2 deletions src/codeatrium/cli/distill_cmd.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""loci distill コマンド"""
"""loci distill コマンド — backend/provider サポート"""

from __future__ import annotations

Expand All @@ -19,6 +19,7 @@ def distill(

from codeatrium.config import load_config
from codeatrium.distiller import distill_all
from codeatrium.llm import DistillBackend
from codeatrium.paths import db_path, find_project_root

root = find_project_root()
Expand Down Expand Up @@ -59,7 +60,7 @@ def _on_progress(cur: int, tot: int, error: str | None = None) -> None:
count, err_count = distill_all(
db,
limit=limit,
model=cfg.distill_model,
backend=DistillBackend.from_config(cfg),
on_progress=_on_progress,
project_root=str(root),
distill_min_chars=cfg.distill_min_chars,
Expand Down
30 changes: 29 additions & 1 deletion src/codeatrium/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""設定ファイルの読み込み — .codeatrium/config.toml"""
"""設定ファイルの読み込み — .codeatrium/config.toml

Supports distill_provider (claude/openai) and distill_base_url for provider switching."""

from __future__ import annotations

Expand All @@ -16,6 +18,8 @@
DEFAULT_DISTILL_BATCH_LIMIT = 20
DEFAULT_INDEX_MIN_CHARS = 50
DEFAULT_DISTILL_MIN_CHARS = 100
DEFAULT_DISTILL_PROVIDER = "claude"
VALID_DISTILL_PROVIDERS = frozenset({"claude", "openai"})


@dataclass
Expand All @@ -26,6 +30,8 @@ class Config:
distill_batch_limit: int = DEFAULT_DISTILL_BATCH_LIMIT
index_min_chars: int = DEFAULT_INDEX_MIN_CHARS
distill_min_chars: int = DEFAULT_DISTILL_MIN_CHARS
distill_provider: str = "claude"
distill_base_url: str | None = None


def load_config(project_root: Path) -> Config:
Expand Down Expand Up @@ -79,9 +85,31 @@ def load_config(project_root: Path) -> Config:
)
distill_min_chars = DEFAULT_DISTILL_MIN_CHARS

provider = distill.get("provider", DEFAULT_DISTILL_PROVIDER)
if not isinstance(provider, str) or provider not in VALID_DISTILL_PROVIDERS:
print(
"Warning: distill.provider must be one of {claude, openai}, using default.",
file=sys.stderr,
)
provider = DEFAULT_DISTILL_PROVIDER

base_url = distill.get("base_url")
if base_url is not None and not isinstance(base_url, str):
base_url = None

if provider == "openai" and (base_url is None or not base_url.strip()):
print(
"Warning: distill.provider is 'openai' but base_url is not set, falling back to 'claude'.",
file=sys.stderr,
)
provider = DEFAULT_DISTILL_PROVIDER
base_url = None

return Config(
distill_model=model,
distill_batch_limit=batch_limit,
index_min_chars=min_chars,
distill_min_chars=distill_min_chars,
distill_provider=provider,
distill_base_url=base_url,
)
15 changes: 9 additions & 6 deletions src/codeatrium/distiller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
② claude -p で palace object 生成(--output-format json --json-schema)
③ distill_text を embedding して vec_palace に登録
④ files_touched を tree-sitter で解析してシンボルを symbols テーブルに登録

backend パラメータで蒸留に使う DistillBackend を指定可能。
"""

from __future__ import annotations
Expand All @@ -19,7 +21,7 @@
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

from codeatrium.embedder import Embedder, EmbedderSetupError
from codeatrium.llm import DISTILL_PROMPT_TEMPLATE, call_claude
from codeatrium.llm import DISTILL_PROMPT_TEMPLATE, DistillBackend, call_claude
from codeatrium.models import PalaceObject
from codeatrium.utils import sha256

Expand Down Expand Up @@ -92,7 +94,7 @@ def distill_exchange(
agent_content: str,
ply_start: int,
ply_end: int,
model: str | None = None,
backend: DistillBackend | None = None,
project_root: str | None = None,
) -> PalaceObject:
"""1つの exchange を蒸留して PalaceObject を返す
Expand All @@ -104,7 +106,7 @@ def distill_exchange(
agent_content: エージェントコンテンツ
ply_start: 開始ply
ply_end: 終了ply
model: 蒸留に使うモデル(デフォルトはconfig.toml から
backend: 蒸留に使う DistillBackend(省略時はデフォルト claude
project_root: プロジェクトルート(ファイルパスフィルタ用)
"""
from codeatrium.db import get_connection
Expand All @@ -115,7 +117,7 @@ def distill_exchange(
ply_end=ply_end,
messages_text=messages_text,
)
raw = call_claude(prompt, model=model)
raw = call_claude(prompt, backend=backend)

# PRIMARY: exchange_files から読み込み
con = get_connection(db_path)
Expand Down Expand Up @@ -302,13 +304,14 @@ def save_palace_object(
def distill_all(
db_path: Path,
limit: int | None = None,
model: str | None = None,
backend: DistillBackend | None = None,
on_progress: Callable[..., None] | None = None,
project_root: str | None = None,
distill_min_chars: int = 100,
) -> tuple[int, int]:
"""未蒸留の exchange を処理する。

backend: 蒸留に使う DistillBackend(省略時はデフォルト claude)
distill_min_chars: この文字数未満の exchange は蒸留スキップ(デフォルト100)
on_progress: (current, total, error=None) を受け取るコールバック
Returns: (処理した exchange 数, エラー数)
Expand Down Expand Up @@ -360,7 +363,7 @@ def distill_all(
row["agent_content"],
row["ply_start"],
row["ply_end"],
model=model,
backend=backend,
project_root=project_root,
)
distill_text = palace.exchange_core + "\n" + palace.specific_context
Expand Down
Loading