Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/worktrees/gallant-bardeen
Submodule gallant-bardeen added at 2c4882
1 change: 1 addition & 0 deletions .claude/worktrees/keen-curran
Submodule keen-curran added at 2c4882
129 changes: 110 additions & 19 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,149 @@
# libucks — Quickstart
# libucks — Quickstart (macOS, /Users/ecaterina/Developer/libucks)

## 1. Install
## 1. Create & activate the virtual environment

```bash
pip install -e ".[dev]"
cd /Users/ecaterina/Developer/libucks
python3 -m venv .venv
source .venv/bin/activate
```

## 2. Required Environment Variables
## 2. Install the package (editable, with dev deps)

```bash
export ANTHROPIC_API_KEY="sk-ant-..."
pip install -e ".[dev]"
```

That is the only secret required. `TextStrategy.from_env()` reads it via the `anthropic` SDK default.
This installs the `libucks` console script and makes `from libucks.xxx import yyy`
importable from anywhere on this Python interpreter.

## 3. Init the current repo
## 3. Set your API key

```bash
libucks init --local "$(pwd)"
export ANTHROPIC_API_KEY="sk-ant-..."
```

## 4. Serve the MCP bridge
`TextStrategy.from_env()` reads it via the `anthropic` SDK default. You only need
this in your shell for manual runs; for Claude Desktop see §5 below.

## 4. Index a repository

```bash
libucks serve
libucks init --local /Users/ecaterina/Developer/libucks
```

Starts the MCP server on **stdio**. Register it in your MCP client (e.g. Claude Code `~/.claude/settings.json`):
This walks the repo, embeds every source file, clusters the chunks, and writes
`.libucks/buckets/` and `.libucks/registry.json` into the target directory.

## 5. Claude Desktop config

Write the following to
`/Users/ecaterina/Library/Application Support/Claude/claude_desktop_config.json`
(create the file if it does not exist — Claude Desktop will not overwrite it on
launch if it is already present):

```json
{
"mcpServers": {
"libucks": {
"command": "libucks",
"args": ["serve"]
"command": "/Users/ecaterina/Developer/libucks/.venv/bin/python",
"args": ["/Users/ecaterina/Developer/libucks/main.py", "serve"],
"env": {
"ANTHROPIC_API_KEY": "sk-ant-YOUR-KEY-HERE",
"PYTHONPATH": "/Users/ecaterina/Developer/libucks"
}
}
}
}
```

## 5. Init a different local repo
**Important notes:**
- Use absolute paths — Claude Desktop does not inherit your shell environment.
- `ANTHROPIC_API_KEY` **must** be in `"env"` for the same reason.
- `PYTHONPATH` is a belt-and-suspenders fallback; the venv Python is the primary
mechanism.
- After saving, **quit and relaunch** Claude Desktop for the config to take effect.

## 6. Verify the server starts

From a fresh terminal (no venv active, any cwd):

```bash
/Users/ecaterina/Developer/libucks/.venv/bin/python \
/Users/ecaterina/Developer/libucks/main.py serve
```

It should block silently, waiting for MCP stdin. Press `Ctrl-C` to stop.
No "Loading weights" or other text should appear on stdout — those messages are
redirected to stderr.

## 7. Index a different repo

```bash
libucks init --local /absolute/path/to/other/repo
```

Then run `libucks serve` from inside that repo (it reads `.libucks/` relative to cwd).
Then update the `"args"` in `claude_desktop_config.json` to point `serve` at the
repo that contains `.libucks/` (the server reads `.libucks/` relative to cwd, or
from `paths.repo_root` in `.libucks/config.toml`).

## 6. PYTHONPATH note
---

**Not required** if installed with `pip install -e .` — the `libucks` console script is on `PATH` and the package is importable.
## Phase 6: Production-Grade Dynamic Engine

If running the entry point directly (e.g. `python main.py serve`), set:
### Step 1 — Start the server

```bash
PYTHONPATH=. python main.py serve
libucks serve
```

This starts the MCP server (stdio) **and** automatically starts two background
tasks inside the same process:

| Background task | What it does | Frequency |
|---|---|---|
| `GitHookReceiver` | Listens on `.libucks/server.sock` for git events | Always-on |
| `HealthMonitor` | Splits overflowing buckets, merges redundant ones | Every 5 min |

### Step 2 — Install git hooks in a repo

Run once per repository you want libucks to track:

```bash
cd /path/to/your/repo
libucks install-hooks
```

This **appends** (never overwrites) three trigger lines to `.git/hooks/`:

```
post-commit → libucks hook post-commit "$@" || true
post-checkout → libucks hook post-checkout "$@" || true
post-rewrite → libucks hook post-rewrite "$@" || true
```

After a `git commit` or `git checkout`, the hook sends a JSON event over the
Unix socket. The server replays any missed diffs and updates `last_indexed_head`.

### Step 3 — Verify the engine is running

Use the `libucks_status` MCP tool from Claude Desktop, or call it directly:

```bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"libucks_status","arguments":{}}}' \
| libucks serve 2>/dev/null
```

The JSON response includes `bucket_count` and `total_tokens`. A healthy system
shows the bucket count stabilising over time as the HealthMonitor splits and
merges.

**Background watcher confirmation** — check the server stderr log for:

```
[libucks] git_hook_receiver.listening sock=.libucks/server.sock
[libucks] health_monitor.started interval=300
```

If you see both lines, all three layers (JIT staleness, git hooks, health monitor)
are active.
83 changes: 83 additions & 0 deletions libucks/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""CLI entry point — lives inside the package so the console script works from any directory."""
import asyncio
import json
import socket
import subprocess
from pathlib import Path

import click


def _find_repo_root() -> Path:
"""Return the git repo root for cwd, or cwd itself if not in a repo."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return Path(result.stdout.strip())
except Exception:
pass
return Path.cwd()


@click.group()
@click.version_option(version="0.1.0", prog_name="libucks")
def cli():
"""libucks — Librarian Buckets, local AI memory server for coding agents."""


@cli.command("init")
@click.option("--local", "local_path", type=click.Path(exists=True, file_okay=False, path_type=Path),
required=True, help="Path to a local repository to index.")
def init_cmd(local_path: Path):
"""Seed libucks buckets from a local repository."""
from libucks.init_orchestrator import InitOrchestrator

orchestrator = InitOrchestrator(local_path)
asyncio.run(orchestrator.run())


@cli.command("serve")
def serve_cmd():
"""Start the libucks MCP server over stdio."""
from libucks.mcp_bridge import serve
asyncio.run(serve())


@cli.command("install-hooks")
@click.option("--repo", "repo_path", type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None, help="Path to repository (defaults to git repo containing cwd).")
def install_hooks_cmd(repo_path: Path | None):
"""Append libucks git hook triggers to .git/hooks/ (never overwrites)."""
from libucks.git_hook_receiver import install_hooks

target = repo_path or _find_repo_root()
modified = install_hooks(target)
if modified:
click.echo(f"Installed hooks: {', '.join(modified)}")
else:
click.echo("All hooks already installed — nothing changed.")


@cli.command("hook")
@click.argument("event")
@click.argument("args", nargs=-1)
def hook_cmd(event: str, args: tuple):
"""Send a git hook event to the running libucks server (called by git hooks)."""
repo_path = _find_repo_root()
sock_path = repo_path / ".libucks" / "server.sock"
if not sock_path.exists():
return # server not running — silent exit so git is never blocked

payload = json.dumps({"event": event, "args": list(args)}).encode()
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect(str(sock_path))
s.sendall(payload)
except Exception:
pass # never block git
49 changes: 38 additions & 11 deletions libucks/diff/diff_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,16 @@ class DiffExtractor:
def __init__(self, repo_path: Path) -> None:
self._repo = git.Repo(str(repo_path), search_parent_directories=True)

def extract(self, filepath: Path) -> List[DiffEvent]:
"""Run ``git diff HEAD -- <filepath> --find-renames`` and parse into DiffEvents."""
rel = str(filepath)
try:
diff_text = self._repo.git.diff(
"HEAD", "--find-renames", "--", rel
)
except git.GitCommandError as exc:
log.warning("diff_extractor.git_error", file=rel, error=str(exc))
return []
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------

def _parse_diff_output(self, diff_text: str, rel: str) -> List[DiffEvent]:
"""Parse raw git diff output into DiffEvents. Shared by extract() and extract_between()."""
if not diff_text:
log.debug("diff_extractor.no_diff", file=rel)
return []

# Detect binary files — git outputs "Binary files … differ"
if "Binary files" in diff_text:
log.warning("diff_extractor.binary_skipped", file=rel)
return []
Expand Down Expand Up @@ -87,3 +81,36 @@ def extract(self, filepath: Path) -> List[DiffEvent]:
)

return events

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def extract(self, filepath: Path) -> List[DiffEvent]:
"""Run ``git diff HEAD -- <filepath> --find-renames`` and parse into DiffEvents."""
rel = str(filepath)
try:
diff_text = self._repo.git.diff("HEAD", "--find-renames", "--", rel)
except git.GitCommandError as exc:
log.warning("diff_extractor.git_error", file=rel, error=str(exc))
return []
return self._parse_diff_output(diff_text, rel)

def extract_between(self, filepath: Path, from_sha: str, to_sha: str) -> List[DiffEvent]:
"""Run ``git diff <from_sha> <to_sha> -- <filepath>`` and parse into DiffEvents.

Used by StartupRecovery to replay commits that occurred while the server was offline.
"""
rel = str(filepath)
try:
diff_text = self._repo.git.diff(from_sha, to_sha, "--find-renames", "--", rel)
except git.GitCommandError as exc:
log.warning(
"diff_extractor.git_error_between",
file=rel,
from_sha=from_sha[:8],
to_sha=to_sha[:8],
error=str(exc),
)
return []
return self._parse_diff_output(diff_text, rel)
4 changes: 2 additions & 2 deletions libucks/embeddings/embedding_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ def reset(cls) -> None:

def embed(self, text: str) -> np.ndarray:
"""Embed a single string and return a normalised float32 vector."""
raw = self._model.encode(text, convert_to_numpy=True)
raw = self._model.encode(text, convert_to_numpy=True, show_progress_bar=False)
return _l2_normalize(raw)

def embed_batch(self, texts: List[str]) -> np.ndarray:
"""Embed a list of strings and return a (N, D) normalised float32 matrix."""
raw = self._model.encode(texts, convert_to_numpy=True)
raw = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return _l2_normalize(raw)
Loading