librarian-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librarian/__init__.py +3 -0
- librarian/__main__.py +3 -0
- librarian/actions/__init__.py +0 -0
- librarian/actions/file_ops.py +47 -0
- librarian/actions/safety.py +29 -0
- librarian/actions/shell_ops.py +49 -0
- librarian/adapter/__init__.py +0 -0
- librarian/adapter/base.py +11 -0
- librarian/adapter/groq_adapter.py +40 -0
- librarian/adapter/openrouter_adapter.py +58 -0
- librarian/cli.py +26 -0
- librarian/commands/__init__.py +0 -0
- librarian/commands/ask.py +46 -0
- librarian/commands/do.py +232 -0
- librarian/commands/init.py +96 -0
- librarian/commands/status.py +71 -0
- librarian/commands/undo.py +85 -0
- librarian/commands/why.py +47 -0
- librarian/exceptions.py +22 -0
- librarian/memory/__init__.py +0 -0
- librarian/memory/capsule.py +94 -0
- librarian/memory/chunker.py +183 -0
- librarian/memory/decision_log.py +36 -0
- librarian/memory/indexer.py +96 -0
- librarian/memory/retriever.py +62 -0
- librarian/orchestrator/__init__.py +0 -0
- librarian/orchestrator/core.py +47 -0
- librarian/orchestrator/router.py +17 -0
- librarian/skills/__init__.py +0 -0
- librarian/skills/bundled/__init__.py +0 -0
- librarian/skills/bundled/api-design/conventions.md +93 -0
- librarian/skills/bundled/python/conventions.md +59 -0
- librarian/skills/bundled/react/conventions.md +83 -0
- librarian/skills/bundled/web-dev/conventions.md +54 -0
- librarian/skills/loader.py +109 -0
- librarian/utils/__init__.py +0 -0
- librarian/utils/config.py +15 -0
- librarian/utils/logger.py +32 -0
- librarian/utils/token_tracker.py +16 -0
- librarian/utils/ui.py +97 -0
- librarian_code-0.1.0.dist-info/METADATA +180 -0
- librarian_code-0.1.0.dist-info/RECORD +45 -0
- librarian_code-0.1.0.dist-info/WHEEL +4 -0
- librarian_code-0.1.0.dist-info/entry_points.txt +2 -0
- librarian_code-0.1.0.dist-info/licenses/LICENSE.md +21 -0
librarian/__init__.py
ADDED
librarian/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
IGNORED_PATHS = [".git", "node_modules", "__pycache__", ".librarian", "venv", ".env"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read_file(path: str) -> str:
|
|
8
|
+
try:
|
|
9
|
+
return Path(path).read_text(encoding="utf-8")
|
|
10
|
+
except UnicodeDecodeError:
|
|
11
|
+
return Path(path).read_text(encoding="latin-1")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_file(path: str, content: str) -> bool:
|
|
15
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
Path(path).write_text(content, encoding="utf-8")
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def edit_file(path: str, old: str, new: str) -> bool:
|
|
21
|
+
content = read_file(path)
|
|
22
|
+
count = content.count(old)
|
|
23
|
+
if count == 0:
|
|
24
|
+
raise ValueError(f"String not found in {path}")
|
|
25
|
+
if count > 1:
|
|
26
|
+
raise ValueError(f"Ambiguous edit: string appears {count} times in {path}")
|
|
27
|
+
content = content.replace(old, new, 1)
|
|
28
|
+
write_file(path, content)
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_files(directory: str, extensions: list[str] = None) -> list[str]:
|
|
33
|
+
ignored = get_ignored_paths()
|
|
34
|
+
results = []
|
|
35
|
+
for root, dirs, files in os.walk(directory):
|
|
36
|
+
dirs[:] = [d for d in dirs if d not in ignored]
|
|
37
|
+
for f in files:
|
|
38
|
+
if extensions:
|
|
39
|
+
ext = os.path.splitext(f)[1].lower()
|
|
40
|
+
if ext not in extensions:
|
|
41
|
+
continue
|
|
42
|
+
results.append(os.path.join(root, f))
|
|
43
|
+
return sorted(results)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_ignored_paths() -> list[str]:
|
|
47
|
+
return IGNORED_PATHS
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RiskLevel(Enum):
|
|
5
|
+
SAFE = "safe"
|
|
6
|
+
CONFIRM = "confirm"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIRM_ACTIONS = [
|
|
10
|
+
"git push",
|
|
11
|
+
"git reset --hard",
|
|
12
|
+
"rm ",
|
|
13
|
+
"delete",
|
|
14
|
+
"drop table",
|
|
15
|
+
"truncate",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def classify_action(action: str) -> RiskLevel:
|
|
20
|
+
action_lower = action.lower()
|
|
21
|
+
for pattern in CONFIRM_ACTIONS:
|
|
22
|
+
if pattern in action_lower:
|
|
23
|
+
return RiskLevel.CONFIRM
|
|
24
|
+
return RiskLevel.SAFE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def request_confirm(action: str) -> bool:
|
|
28
|
+
from rich.prompt import Confirm
|
|
29
|
+
return Confirm.ask(f"[bold #F59E0B]confirm:[/bold #F59E0B] {action}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shlex
|
|
3
|
+
from librarian.actions.safety import classify_action, RiskLevel, request_confirm
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_command(cmd: str, cwd: str = None) -> tuple[int, str, str]:
|
|
7
|
+
if isinstance(cmd, str):
|
|
8
|
+
args = shlex.split(cmd)
|
|
9
|
+
else:
|
|
10
|
+
args = cmd
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
args, shell=False, cwd=cwd,
|
|
13
|
+
capture_output=True, text=True,
|
|
14
|
+
)
|
|
15
|
+
return result.returncode, result.stdout, result.stderr
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def git_stage(files: list[str]) -> bool:
|
|
19
|
+
args = ["git", "add"] + files
|
|
20
|
+
code, _, err = run_command(args)
|
|
21
|
+
if code != 0:
|
|
22
|
+
raise RuntimeError(f"git add failed: {err}")
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def git_commit(message: str) -> bool:
|
|
27
|
+
args = ["git", "commit", "-m", message]
|
|
28
|
+
code, _, err = run_command(args)
|
|
29
|
+
if code != 0:
|
|
30
|
+
raise RuntimeError(f"git commit failed: {err}")
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def git_push() -> bool:
|
|
35
|
+
risk = classify_action("git push")
|
|
36
|
+
if risk == RiskLevel.CONFIRM:
|
|
37
|
+
if not request_confirm("push to remote?"):
|
|
38
|
+
return False
|
|
39
|
+
code, _, err = run_command("git push")
|
|
40
|
+
if code != 0:
|
|
41
|
+
raise RuntimeError(f"git push failed: {err}")
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def git_status() -> str:
|
|
46
|
+
code, out, err = run_command("git status --short")
|
|
47
|
+
if code != 0:
|
|
48
|
+
return err
|
|
49
|
+
return out.strip()
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from groq import Groq, RateLimitError as GroqRateLimitError, APIConnectionError
|
|
2
|
+
from librarian.adapter.base import LLMAdapter
|
|
3
|
+
from librarian.exceptions import RateLimitError, ProviderUnavailableError
|
|
4
|
+
from librarian.utils.config import GROQ_API_KEY
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GroqAdapter(LLMAdapter):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
|
|
10
|
+
self.model = "llama-3.3-70b-versatile"
|
|
11
|
+
self.tokens_used = 0
|
|
12
|
+
|
|
13
|
+
def complete(self, system: str, prompt: str) -> str:
|
|
14
|
+
if not self.client:
|
|
15
|
+
raise ProviderUnavailableError("GROQ_API_KEY not set")
|
|
16
|
+
try:
|
|
17
|
+
response = self.client.chat.completions.create(
|
|
18
|
+
model=self.model,
|
|
19
|
+
messages=[
|
|
20
|
+
{"role": "system", "content": system},
|
|
21
|
+
{"role": "user", "content": prompt},
|
|
22
|
+
],
|
|
23
|
+
temperature=0.2,
|
|
24
|
+
max_tokens=4096,
|
|
25
|
+
)
|
|
26
|
+
self.tokens_used += response.usage.total_tokens
|
|
27
|
+
return response.choices[0].message.content
|
|
28
|
+
except GroqRateLimitError:
|
|
29
|
+
raise RateLimitError("Groq rate limit exceeded")
|
|
30
|
+
except APIConnectionError:
|
|
31
|
+
raise ProviderUnavailableError("Cannot connect to Groq")
|
|
32
|
+
|
|
33
|
+
def is_available(self) -> bool:
|
|
34
|
+
if not self.client:
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
self.client.models.list()
|
|
38
|
+
return True
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from librarian.adapter.base import LLMAdapter
|
|
3
|
+
from librarian.exceptions import RateLimitError, ProviderUnavailableError
|
|
4
|
+
from librarian.utils.config import OPENROUTER_API_KEY
|
|
5
|
+
|
|
6
|
+
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
|
7
|
+
MODEL = "qwen/qwen3-coder:free"
|
|
8
|
+
HEADERS = {
|
|
9
|
+
"HTTP-Referer": "https://github.com/Humble-Librarian/librarian-code",
|
|
10
|
+
"X-Title": "librarian",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenRouterAdapter(LLMAdapter):
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.api_key = OPENROUTER_API_KEY
|
|
17
|
+
self.tokens_used = 0
|
|
18
|
+
|
|
19
|
+
def complete(self, system: str, prompt: str) -> str:
|
|
20
|
+
if not self.api_key:
|
|
21
|
+
raise ProviderUnavailableError("OPENROUTER_API_KEY not set")
|
|
22
|
+
headers = {**HEADERS, "Authorization": f"Bearer {self.api_key}"}
|
|
23
|
+
payload = {
|
|
24
|
+
"model": MODEL,
|
|
25
|
+
"messages": [
|
|
26
|
+
{"role": "system", "content": system},
|
|
27
|
+
{"role": "user", "content": prompt},
|
|
28
|
+
],
|
|
29
|
+
"temperature": 0.2,
|
|
30
|
+
"max_tokens": 4096,
|
|
31
|
+
}
|
|
32
|
+
try:
|
|
33
|
+
with httpx.Client(timeout=60) as client:
|
|
34
|
+
resp = client.post(ENDPOINT, headers=headers, json=payload)
|
|
35
|
+
if resp.status_code == 429:
|
|
36
|
+
raise RateLimitError("OpenRouter rate limit exceeded")
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
data = resp.json()
|
|
39
|
+
self.tokens_used += data.get("usage", {}).get("total_tokens", 0)
|
|
40
|
+
choices = data.get("choices", [])
|
|
41
|
+
if not choices or "message" not in choices[0]:
|
|
42
|
+
raise ProviderUnavailableError("Invalid API response format")
|
|
43
|
+
return choices[0]["message"]["content"]
|
|
44
|
+
except httpx.ConnectError:
|
|
45
|
+
raise ProviderUnavailableError("Cannot connect to OpenRouter")
|
|
46
|
+
except httpx.TimeoutException:
|
|
47
|
+
raise ProviderUnavailableError("OpenRouter request timed out")
|
|
48
|
+
|
|
49
|
+
def is_available(self) -> bool:
|
|
50
|
+
if not self.api_key:
|
|
51
|
+
return False
|
|
52
|
+
try:
|
|
53
|
+
headers = {**HEADERS, "Authorization": f"Bearer {self.api_key}"}
|
|
54
|
+
with httpx.Client(timeout=10) as client:
|
|
55
|
+
resp = client.get("https://openrouter.ai/api/v1/models", headers=headers)
|
|
56
|
+
return resp.status_code == 200
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
librarian/cli.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from librarian.commands import init, ask, do, why, undo, status
|
|
3
|
+
from librarian.utils.ui import print_banner, print_muted, print_warning
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="librarian",
|
|
7
|
+
help="A CLI coding agent with persistent project memory.",
|
|
8
|
+
add_completion=False,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def main(ctx: typer.Context):
|
|
14
|
+
if ctx.invoked_subcommand is None:
|
|
15
|
+
print_banner()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
app.command(name="init")(init.run)
|
|
19
|
+
app.command(name="ask")(ask.run)
|
|
20
|
+
app.command(name="do")(do.run)
|
|
21
|
+
app.command(name="why")(why.run)
|
|
22
|
+
app.command(name="undo")(undo.run)
|
|
23
|
+
app.command(name="status")(status.run)
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from librarian.utils.ui import print_header, print_warning, print_panel, print_muted
|
|
3
|
+
from librarian.utils.token_tracker import tracker
|
|
4
|
+
from librarian.orchestrator.core import ask as ask_llm
|
|
5
|
+
from librarian.memory.retriever import retrieve
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_api_keys():
|
|
9
|
+
from librarian.utils.config import GROQ_API_KEY, OPENROUTER_API_KEY
|
|
10
|
+
if not GROQ_API_KEY and not OPENROUTER_API_KEY:
|
|
11
|
+
print_warning("no API keys found")
|
|
12
|
+
print_muted(" set at least one API key in .env file:")
|
|
13
|
+
print_muted("")
|
|
14
|
+
print_muted(" GROQ_API_KEY=gsk_... (free at console.groq.com)")
|
|
15
|
+
print_muted(" OPENROUTER_API_KEY=sk-or-... (free at openrouter.ai)")
|
|
16
|
+
print_muted("")
|
|
17
|
+
return False
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(task: str):
|
|
22
|
+
if not Path(".librarian").exists():
|
|
23
|
+
print_header("librarian ask")
|
|
24
|
+
print_warning("project not initialised — run 'librarian init' first")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
if not _check_api_keys():
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
print_header("librarian ask")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
chunks = retrieve(task, n_results=5)
|
|
34
|
+
sources = []
|
|
35
|
+
for c in chunks:
|
|
36
|
+
meta = c["metadata"]
|
|
37
|
+
sources.append(f"{meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')}")
|
|
38
|
+
|
|
39
|
+
response, provider, tokens = ask_llm(task)
|
|
40
|
+
tracker.add(provider, tokens)
|
|
41
|
+
print_panel(response, title="answer")
|
|
42
|
+
if sources:
|
|
43
|
+
print_muted(f" sources {', '.join(sources[:3])}")
|
|
44
|
+
print_muted(f" tokens {tokens} provider {provider}")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print_warning(f"error: {e}")
|
librarian/commands/do.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from librarian.utils.ui import (
|
|
6
|
+
print_header, print_warning, print_success, print_muted,
|
|
7
|
+
print_panel, confirm_action, console, INDIGO, WARNING, SUCCESS,
|
|
8
|
+
)
|
|
9
|
+
from librarian.utils.token_tracker import tracker
|
|
10
|
+
from librarian.orchestrator.core import read_librarian_md, build_system_prompt
|
|
11
|
+
from librarian.orchestrator.router import get_response
|
|
12
|
+
from librarian.memory.retriever import retrieve
|
|
13
|
+
from librarian.memory import capsule, decision_log
|
|
14
|
+
from librarian.actions.file_ops import read_file, write_file, edit_file
|
|
15
|
+
from librarian.actions.shell_ops import run_command
|
|
16
|
+
from librarian.actions.safety import classify_action, RiskLevel
|
|
17
|
+
from librarian.skills.loader import build_skill_context
|
|
18
|
+
|
|
19
|
+
DO_SYSTEM_PROMPT = """You are Librarian, a CLI coding agent. Respond ONLY with a JSON plan.
|
|
20
|
+
|
|
21
|
+
ACTION TYPES:
|
|
22
|
+
|
|
23
|
+
1. create_file — for new files:
|
|
24
|
+
{"type":"create_file","file":"path","description":"what","content":"COMPLETE file"}
|
|
25
|
+
|
|
26
|
+
2. edit_file — modify existing files:
|
|
27
|
+
{"type":"edit_file","file":"path","description":"what","old_code":"EXACT text","new_code":"replacement"}
|
|
28
|
+
|
|
29
|
+
3. delete_file — remove files/folders:
|
|
30
|
+
{"type":"delete_file","file":"path","description":"why"}
|
|
31
|
+
|
|
32
|
+
4. shell_command — run terminal commands:
|
|
33
|
+
{"type":"shell_command","command":"cmd","description":"what"}
|
|
34
|
+
|
|
35
|
+
RESPONSE FORMAT:
|
|
36
|
+
{"reasoning":"approach","actions":[...]}
|
|
37
|
+
|
|
38
|
+
RULES:
|
|
39
|
+
- content in create_file MUST be the complete, working, FULL file — never a stub, placeholder, or comment
|
|
40
|
+
- NEVER generate content like "// add code here" or empty tags — always write real, functional code
|
|
41
|
+
- For web projects (HTML/CSS/JS): prefer a single index.html with inline <style> and <script>
|
|
42
|
+
- old_code in edit_file must match the file EXACTLY including whitespace — if unsure, use create_file instead
|
|
43
|
+
- Return ONLY valid JSON — no markdown fences, no explanation
|
|
44
|
+
- Keep file contents under 200 lines to avoid truncation
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_plan(raw: str) -> dict:
|
|
49
|
+
raw = raw.strip()
|
|
50
|
+
if raw.startswith("```"):
|
|
51
|
+
raw = re.sub(r"^```(?:json)?\n?", "", raw)
|
|
52
|
+
raw = re.sub(r"\n?```$", "", raw)
|
|
53
|
+
|
|
54
|
+
raw = raw.replace("\\'", "'")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(raw)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
last_bracket = raw.rfind("]")
|
|
62
|
+
if last_bracket == -1:
|
|
63
|
+
raise json.JSONDecodeError("No JSON array found", raw, 0)
|
|
64
|
+
truncated = raw[:last_bracket] + "]}"
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(truncated)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
raise json.JSONDecodeError("Could not parse plan", raw, 0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_chunks(chunks: list[dict]) -> str:
|
|
73
|
+
parts = []
|
|
74
|
+
for c in chunks:
|
|
75
|
+
meta = c["metadata"]
|
|
76
|
+
parts.append(f"--- {meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')} ---\n{c['content']}")
|
|
77
|
+
return "\n\n".join(parts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _show_plan(plan: dict, task: str):
|
|
81
|
+
print_panel(
|
|
82
|
+
f" task {task}\n\n reasoning {plan.get('reasoning', '—')}",
|
|
83
|
+
title="execution plan",
|
|
84
|
+
)
|
|
85
|
+
from rich.table import Table
|
|
86
|
+
table = Table(show_header=True, header_style=f"bold {INDIGO}")
|
|
87
|
+
table.add_column("#", width=3)
|
|
88
|
+
table.add_column("type", width=14)
|
|
89
|
+
table.add_column("description", width=40)
|
|
90
|
+
for i, action in enumerate(plan.get("actions", []), 1):
|
|
91
|
+
table.add_row(
|
|
92
|
+
str(i),
|
|
93
|
+
action.get("type", "?"),
|
|
94
|
+
action.get("description", "—"),
|
|
95
|
+
)
|
|
96
|
+
console.print(table)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _execute_action(action: dict) -> dict:
|
|
100
|
+
action_type = action.get("type")
|
|
101
|
+
if action_type == "edit_file":
|
|
102
|
+
path = Path(action["file"])
|
|
103
|
+
if not path.exists():
|
|
104
|
+
raise FileNotFoundError(f"File not found: {action['file']}")
|
|
105
|
+
content = read_file(action["file"])
|
|
106
|
+
if action["old_code"] not in content:
|
|
107
|
+
raise ValueError(f"old_code not found in {action['file']} — file may have changed")
|
|
108
|
+
edit_file(action["file"], action["old_code"], action["new_code"])
|
|
109
|
+
return {"type": "edit_file", "file": action["file"], "status": "done"}
|
|
110
|
+
elif action_type == "create_file":
|
|
111
|
+
path = Path(action["file"])
|
|
112
|
+
if path.exists() and path.stat().st_size > 0:
|
|
113
|
+
content = action.get("content", "")
|
|
114
|
+
if not content or len(content.strip()) < 20:
|
|
115
|
+
raise ValueError(f"Refusing to overwrite {action['file']} with empty/stub content")
|
|
116
|
+
write_file(action["file"], action["content"])
|
|
117
|
+
return {"type": "create_file", "file": action["file"], "status": "done"}
|
|
118
|
+
elif action_type == "delete_file":
|
|
119
|
+
target = Path(action["file"])
|
|
120
|
+
if target.is_dir():
|
|
121
|
+
shutil.rmtree(target)
|
|
122
|
+
elif target.is_file():
|
|
123
|
+
target.unlink()
|
|
124
|
+
else:
|
|
125
|
+
raise FileNotFoundError(f"Not found: {action['file']}")
|
|
126
|
+
return {"type": "delete_file", "file": action["file"], "status": "done"}
|
|
127
|
+
elif action_type == "shell_command":
|
|
128
|
+
cmd = action["command"]
|
|
129
|
+
if cmd.strip().startswith("rm "):
|
|
130
|
+
import re as _re
|
|
131
|
+
paths = _re.findall(r"(?:^|\s)(\S+)", cmd.replace("rm ", "", 1))
|
|
132
|
+
for p in paths:
|
|
133
|
+
p = p.lstrip("-").lstrip("r").lstrip("f").strip()
|
|
134
|
+
target = Path(p)
|
|
135
|
+
if target.is_dir():
|
|
136
|
+
shutil.rmtree(target)
|
|
137
|
+
elif target.is_file():
|
|
138
|
+
target.unlink()
|
|
139
|
+
return {"type": "shell_command", "command": cmd, "status": "done"}
|
|
140
|
+
code, out, err = run_command(cmd)
|
|
141
|
+
return {"type": "shell_command", "command": cmd, "status": "done" if code == 0 else f"exit {code}"}
|
|
142
|
+
return {"type": action_type, "status": "unknown"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_api_keys():
|
|
146
|
+
from librarian.utils.config import GROQ_API_KEY, OPENROUTER_API_KEY
|
|
147
|
+
if not GROQ_API_KEY and not OPENROUTER_API_KEY:
|
|
148
|
+
print_warning("no API keys found")
|
|
149
|
+
print_muted(" set at least one API key in .env file:")
|
|
150
|
+
print_muted("")
|
|
151
|
+
print_muted(" GROQ_API_KEY=gsk_... (free at console.groq.com)")
|
|
152
|
+
print_muted(" OPENROUTER_API_KEY=sk-or-... (free at openrouter.ai)")
|
|
153
|
+
print_muted("")
|
|
154
|
+
return False
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run(task: str):
|
|
159
|
+
if not Path(".librarian").exists():
|
|
160
|
+
print_header("librarian do")
|
|
161
|
+
print_warning("project not initialised — run 'librarian init' first")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if not _check_api_keys():
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
print_header("librarian do")
|
|
168
|
+
|
|
169
|
+
chunks = retrieve(task, n_results=7)
|
|
170
|
+
conventions = read_librarian_md()
|
|
171
|
+
skill_ctx = build_skill_context()
|
|
172
|
+
|
|
173
|
+
parts = [f"Project conventions:\n{conventions}"]
|
|
174
|
+
if skill_ctx:
|
|
175
|
+
parts.append(f"Domain best practices:\n{skill_ctx}")
|
|
176
|
+
if chunks:
|
|
177
|
+
context = _format_chunks(chunks)
|
|
178
|
+
parts.append(f"Relevant code:\n{context}")
|
|
179
|
+
parts.append(f"Task: {task}")
|
|
180
|
+
prompt = "\n\n".join(parts)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
raw_response, provider, tokens = get_response(DO_SYSTEM_PROMPT, prompt)
|
|
184
|
+
tracker.add(provider, tokens)
|
|
185
|
+
plan = _parse_plan(raw_response)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
print_warning("LLM returned invalid JSON — try rephrasing your task")
|
|
188
|
+
return
|
|
189
|
+
except Exception as e:
|
|
190
|
+
print_warning(f"error: {e}")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
_show_plan(plan, task)
|
|
194
|
+
|
|
195
|
+
if not confirm_action("proceed with execution?"):
|
|
196
|
+
print_muted(" cancelled")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
results = []
|
|
200
|
+
files_changed = []
|
|
201
|
+
for action in plan.get("actions", []):
|
|
202
|
+
risk_text = action.get("description", "") + " " + action.get("command", "") + " " + action.get("file", "")
|
|
203
|
+
if action.get("type") in ("delete_file",):
|
|
204
|
+
risk_text += " delete"
|
|
205
|
+
risk = classify_action(risk_text)
|
|
206
|
+
if risk == RiskLevel.CONFIRM:
|
|
207
|
+
if not confirm_action(f"execute: {action.get('description', '?')}"):
|
|
208
|
+
print_muted(f" skipped: {action.get('description', '?')}")
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
result = _execute_action(action)
|
|
212
|
+
results.append(result)
|
|
213
|
+
if "file" in action:
|
|
214
|
+
files_changed.append(action["file"])
|
|
215
|
+
print_success(f"done: {action.get('description', '?')}")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print_warning(f"failed: {action.get('description', '?')} — {e}")
|
|
218
|
+
|
|
219
|
+
decision_log.append({
|
|
220
|
+
"command": "do",
|
|
221
|
+
"task": task,
|
|
222
|
+
"actions_taken": results,
|
|
223
|
+
"files_changed": files_changed,
|
|
224
|
+
"llm_provider": provider,
|
|
225
|
+
"tokens_used": tokens,
|
|
226
|
+
"reasoning": plan.get("reasoning", ""),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
if results:
|
|
230
|
+
capsule.create(task, plan.get("reasoning", ""), files_changed)
|
|
231
|
+
print_success(f"{len(results)} actions completed")
|
|
232
|
+
print_muted(f" tokens: {tokens} provider: {provider}")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from librarian.utils.ui import print_header, print_success, print_warning, print_muted
|
|
4
|
+
from librarian.memory.indexer import index_project
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _detect_languages() -> list[str]:
|
|
8
|
+
from librarian.actions.file_ops import list_files
|
|
9
|
+
files = list_files(".", None)
|
|
10
|
+
exts = set()
|
|
11
|
+
for f in files:
|
|
12
|
+
ext = os.path.splitext(f)[1].lower()
|
|
13
|
+
if ext:
|
|
14
|
+
exts.add(ext)
|
|
15
|
+
lang_map = {
|
|
16
|
+
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
|
17
|
+
".jsx": "React JSX", ".tsx": "React TSX", ".go": "Go",
|
|
18
|
+
".rs": "Rust", ".java": "Java", ".rb": "Ruby",
|
|
19
|
+
".md": "Markdown", ".txt": "Text",
|
|
20
|
+
}
|
|
21
|
+
return [lang_map.get(e, e) for e in sorted(exts) if e in lang_map]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detect_package_manager() -> str:
|
|
25
|
+
if Path("pyproject.toml").exists():
|
|
26
|
+
return "uv / pip"
|
|
27
|
+
if Path("setup.py").exists():
|
|
28
|
+
return "pip"
|
|
29
|
+
if Path("package.json").exists():
|
|
30
|
+
return "npm"
|
|
31
|
+
if Path("Cargo.toml").exists():
|
|
32
|
+
return "cargo"
|
|
33
|
+
return "unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _generate_librarian_md(languages: list[str], package_manager: str):
|
|
37
|
+
content = f"""# LIBRARIAN.md — project conventions
|
|
38
|
+
|
|
39
|
+
## language
|
|
40
|
+
{', '.join(languages) if languages else 'unknown'}
|
|
41
|
+
|
|
42
|
+
## package manager
|
|
43
|
+
{package_manager}
|
|
44
|
+
|
|
45
|
+
## style
|
|
46
|
+
- follow existing code conventions in the project
|
|
47
|
+
- use type hints where applicable
|
|
48
|
+
- keep functions focused and small
|
|
49
|
+
|
|
50
|
+
## structure
|
|
51
|
+
(add project structure notes here)
|
|
52
|
+
|
|
53
|
+
## things to avoid
|
|
54
|
+
- importing specific adapters directly (always use base.LLMAdapter)
|
|
55
|
+
- hardcoding API keys
|
|
56
|
+
- deleting files without confirmation
|
|
57
|
+
|
|
58
|
+
## notes
|
|
59
|
+
(add project-specific notes here — librarian reads this on every run)
|
|
60
|
+
"""
|
|
61
|
+
Path("LIBRARIAN.md").write_text(content, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run():
|
|
65
|
+
print_header("initialising project")
|
|
66
|
+
|
|
67
|
+
cwd = os.getcwd()
|
|
68
|
+
basename = os.path.basename(cwd)
|
|
69
|
+
if not basename or len(basename) < 2:
|
|
70
|
+
print_warning("cannot initialise from a root or drive directory")
|
|
71
|
+
print_muted(" cd into your project folder first, then run: librarian init")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
librarian_dir = Path(".librarian")
|
|
75
|
+
if librarian_dir.exists():
|
|
76
|
+
print_warning(".librarian/ already exists — re-indexing")
|
|
77
|
+
|
|
78
|
+
librarian_dir.mkdir(exist_ok=True)
|
|
79
|
+
|
|
80
|
+
if not Path("LIBRARIAN.md").exists():
|
|
81
|
+
languages = _detect_languages()
|
|
82
|
+
pkg = _detect_package_manager()
|
|
83
|
+
_generate_librarian_md(languages, pkg)
|
|
84
|
+
print_success("LIBRARIAN.md created")
|
|
85
|
+
else:
|
|
86
|
+
print_muted(" LIBRARIAN.md already exists — skipping")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
meta = index_project()
|
|
90
|
+
print_success(f"{meta['file_count']} files, {meta['chunk_count']} chunks")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print_warning(f"indexing error: {e}")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
print_success(".librarian/ initialised")
|
|
96
|
+
print_muted("\n ready. run: librarian ask \"what does this project do?\"")
|