librarian-code 0.1.0__tar.gz → 0.2.0__tar.gz
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_code-0.2.0/.mimocode/plans/1782009063038-gentle-garden.md +88 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/PKG-INFO +1 -1
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/__init__.py +1 -1
- librarian_code-0.2.0/librarian/actions/verify.py +40 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/base.py +5 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/groq_adapter.py +23 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/openrouter_adapter.py +38 -0
- librarian_code-0.2.0/librarian/cli.py +74 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/ask.py +4 -3
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/do.py +38 -2
- librarian_code-0.2.0/librarian/commands/git_cmd.py +98 -0
- librarian_code-0.2.0/librarian/commands/repl.py +70 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/capsule.py +9 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/retriever.py +11 -2
- librarian_code-0.2.0/librarian/memory/session.py +48 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/core.py +16 -4
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/router.py +13 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/loader.py +33 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/config.py +11 -0
- librarian_code-0.2.0/librarian/utils/toml_config.py +36 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/ui.py +29 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/pyproject.toml +1 -1
- librarian_code-0.1.0/.mimocode/plans/1782009063038-gentle-garden.md +0 -95
- librarian_code-0.1.0/librarian/cli.py +0 -26
- {librarian_code-0.1.0 → librarian_code-0.2.0}/.gitignore +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/LICENSE.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/README.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/__main__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/file_ops.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/safety.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/shell_ops.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/init.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/status.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/undo.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/why.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/exceptions.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/chunker.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/decision_log.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/indexer.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/api-design/conventions.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/python/conventions.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/react/conventions.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/web-dev/conventions.md +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/logger.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/token_tracker.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/__init__.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_actions.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_adapter.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_commands.py +0 -0
- {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_memory.py +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Plan: Librarian Feature Roadmap
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Add high-impact features to differentiate Librarian from basic CLI coding agents.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## P0 — Must Have (Core UX)
|
|
9
|
+
|
|
10
|
+
### 1. Streaming Output
|
|
11
|
+
Show LLM tokens as they arrive instead of waiting for full response.
|
|
12
|
+
- **Why**: Eliminates "hung" perception during 10s+ generation
|
|
13
|
+
- **Complexity**: Medium
|
|
14
|
+
- **Files**: `adapter/base.py`, `adapter/groq_adapter.py`, `adapter/openrouter_adapter.py`, `commands/ask.py`, `commands/do.py`
|
|
15
|
+
|
|
16
|
+
### 2. Diff Preview Before Execute
|
|
17
|
+
Show syntax-highlighted file diffs before user confirms changes.
|
|
18
|
+
- **Why**: Safety — users approve exact changes, not blind JSON plans
|
|
19
|
+
- **Complexity**: Low
|
|
20
|
+
- **Files**: `commands/do.py`, `actions/file_ops.py`, `utils/ui.py`
|
|
21
|
+
|
|
22
|
+
### 3. Multi-Turn Conversation
|
|
23
|
+
Maintain session context across consecutive `ask`/`do` calls.
|
|
24
|
+
- **Why**: Essential for iterative coding; current stateless design forces re-retrieval each turn
|
|
25
|
+
- **Complexity**: Medium
|
|
26
|
+
- **Files**: `orchestrator/core.py`, `commands/ask.py`, `commands/do.py`, new `memory/session.py`
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## P1 — Should Have (Quality & Precision)
|
|
31
|
+
|
|
32
|
+
### 4. File Targeting (`--file`)
|
|
33
|
+
Scope retrieval to specific files or directories.
|
|
34
|
+
- **Why**: Precision reduces irrelevant context; faster, cheaper, more accurate
|
|
35
|
+
- **Complexity**: Low
|
|
36
|
+
- **Files**: `commands/ask.py`, `commands/do.py`, `memory/retriever.py`
|
|
37
|
+
|
|
38
|
+
### 5. Post-Change Test/Lint
|
|
39
|
+
Auto-run tests/lint after `do` executes changes.
|
|
40
|
+
- **Why**: Catches regressions immediately; differentiator vs basic agents
|
|
41
|
+
- **Complexity**: Medium
|
|
42
|
+
- **Files**: `commands/do.py`, new `actions/verify.py`, `utils/config.py`
|
|
43
|
+
|
|
44
|
+
### 6. Git CLI Command
|
|
45
|
+
`librarian commit`, `librarian push`, `librarian diff`
|
|
46
|
+
- **Why**: Wire existing `shell_ops.py` helpers into first-class commands
|
|
47
|
+
- **Complexity**: Low
|
|
48
|
+
- **Files**: `cli.py`, new `commands/git.py`, `actions/shell_ops.py`
|
|
49
|
+
|
|
50
|
+
### 7. Capsule Feedback Loop
|
|
51
|
+
Feed undo/approve signals back into retrieval ranking.
|
|
52
|
+
- **Why**: Memory improves over time; currently capsules decay but never inform search
|
|
53
|
+
- **Complexity**: Medium
|
|
54
|
+
- **Files**: `memory/capsule.py`, `memory/retriever.py`, `orchestrator/core.py`
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## P2 — Nice to Have (Extensibility)
|
|
59
|
+
|
|
60
|
+
### 8. Custom Skills
|
|
61
|
+
User-defined skill files loaded per-project.
|
|
62
|
+
- **Why**: Power users tailor agent behavior to their stack
|
|
63
|
+
- **Complexity**: Medium
|
|
64
|
+
- **Files**: `skills/loader.py`, `orchestrator/core.py`
|
|
65
|
+
|
|
66
|
+
### 9. Interactive REPL
|
|
67
|
+
`librarian repl` with persistent session.
|
|
68
|
+
- **Why**: Exploratory workflows; reduces startup cost per query
|
|
69
|
+
- **Complexity**: High
|
|
70
|
+
- **Files**: New `commands/repl.py`, `cli.py`, all commands
|
|
71
|
+
|
|
72
|
+
### 10. TOML/YAML Config
|
|
73
|
+
`librarian.toml` replacing raw `.env`.
|
|
74
|
+
- **Why**: Structured config with defaults, per-project overrides
|
|
75
|
+
- **Complexity**: Low
|
|
76
|
+
- **Files**: `utils/config.py`, `cli.py`
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Implementation Order
|
|
81
|
+
|
|
82
|
+
Start P0 (streaming → diff preview → multi-turn), then P1, then P2.
|
|
83
|
+
|
|
84
|
+
## Verification
|
|
85
|
+
|
|
86
|
+
1. `python -m pytest tests/` — all existing tests pass
|
|
87
|
+
2. Manual test each new command
|
|
88
|
+
3. `python -m build && twine check dist/*` — package builds clean
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: librarian-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A local-first CLI coding agent with persistent project memory
|
|
5
5
|
Project-URL: Homepage, https://github.com/Humble-Librarian/Librarian-Code
|
|
6
6
|
Project-URL: Repository, https://github.com/Humble-Librarian/Librarian-Code
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from librarian.actions.shell_ops import run_command
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_tests() -> tuple[bool, str]:
|
|
7
|
+
if Path("pyproject.toml").exists():
|
|
8
|
+
code, out, err = run_command("python -m pytest tests/ -v --tb=short -q")
|
|
9
|
+
return code == 0, out + err
|
|
10
|
+
if Path("package.json").exists():
|
|
11
|
+
code, out, err = run_command("npm test")
|
|
12
|
+
return code == 0, out + err
|
|
13
|
+
return True, "no test runner detected"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_lint() -> tuple[bool, str]:
|
|
17
|
+
if Path("pyproject.toml").exists():
|
|
18
|
+
code, out, err = run_command("python -m ruff check .")
|
|
19
|
+
if code != 0 and "No module named 'ruff'" in err:
|
|
20
|
+
return True, "ruff not installed"
|
|
21
|
+
return code == 0, out + err
|
|
22
|
+
if Path("package.json").exists():
|
|
23
|
+
code, out, err = run_command("npm run lint")
|
|
24
|
+
return code == 0, out + err
|
|
25
|
+
return True, "no linter detected"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify_changes() -> tuple[bool, str]:
|
|
29
|
+
lint_ok, lint_out = run_lint()
|
|
30
|
+
tests_ok, tests_out = run_tests()
|
|
31
|
+
|
|
32
|
+
parts = []
|
|
33
|
+
if not lint_ok:
|
|
34
|
+
parts.append(f"lint failed:\n{lint_out}")
|
|
35
|
+
if not tests_ok:
|
|
36
|
+
parts.append(f"tests failed:\n{tests_out}")
|
|
37
|
+
|
|
38
|
+
if parts:
|
|
39
|
+
return False, "\n\n".join(parts)
|
|
40
|
+
return True, "all checks passed"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Iterator
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
class LLMAdapter(ABC):
|
|
@@ -6,6 +7,10 @@ class LLMAdapter(ABC):
|
|
|
6
7
|
def complete(self, system: str, prompt: str) -> str:
|
|
7
8
|
pass
|
|
8
9
|
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def complete_stream(self, system: str, prompt: str) -> Iterator[str]:
|
|
12
|
+
yield ""
|
|
13
|
+
|
|
9
14
|
@abstractmethod
|
|
10
15
|
def is_available(self) -> bool:
|
|
11
16
|
pass
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from typing import Iterator
|
|
1
2
|
from groq import Groq, RateLimitError as GroqRateLimitError, APIConnectionError
|
|
2
3
|
from librarian.adapter.base import LLMAdapter
|
|
3
4
|
from librarian.exceptions import RateLimitError, ProviderUnavailableError
|
|
@@ -30,6 +31,28 @@ class GroqAdapter(LLMAdapter):
|
|
|
30
31
|
except APIConnectionError:
|
|
31
32
|
raise ProviderUnavailableError("Cannot connect to Groq")
|
|
32
33
|
|
|
34
|
+
def complete_stream(self, system: str, prompt: str) -> Iterator[str]:
|
|
35
|
+
if not self.client:
|
|
36
|
+
raise ProviderUnavailableError("GROQ_API_KEY not set")
|
|
37
|
+
try:
|
|
38
|
+
stream = self.client.chat.completions.create(
|
|
39
|
+
model=self.model,
|
|
40
|
+
messages=[
|
|
41
|
+
{"role": "system", "content": system},
|
|
42
|
+
{"role": "user", "content": prompt},
|
|
43
|
+
],
|
|
44
|
+
temperature=0.2,
|
|
45
|
+
max_tokens=4096,
|
|
46
|
+
stream=True,
|
|
47
|
+
)
|
|
48
|
+
for chunk in stream:
|
|
49
|
+
if chunk.choices[0].delta.content:
|
|
50
|
+
yield chunk.choices[0].delta.content
|
|
51
|
+
except GroqRateLimitError:
|
|
52
|
+
raise RateLimitError("Groq rate limit exceeded")
|
|
53
|
+
except APIConnectionError:
|
|
54
|
+
raise ProviderUnavailableError("Cannot connect to Groq")
|
|
55
|
+
|
|
33
56
|
def is_available(self) -> bool:
|
|
34
57
|
if not self.client:
|
|
35
58
|
return False
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Iterator
|
|
1
3
|
import httpx
|
|
2
4
|
from librarian.adapter.base import LLMAdapter
|
|
3
5
|
from librarian.exceptions import RateLimitError, ProviderUnavailableError
|
|
@@ -46,6 +48,42 @@ class OpenRouterAdapter(LLMAdapter):
|
|
|
46
48
|
except httpx.TimeoutException:
|
|
47
49
|
raise ProviderUnavailableError("OpenRouter request timed out")
|
|
48
50
|
|
|
51
|
+
def complete_stream(self, system: str, prompt: str) -> Iterator[str]:
|
|
52
|
+
if not self.api_key:
|
|
53
|
+
raise ProviderUnavailableError("OPENROUTER_API_KEY not set")
|
|
54
|
+
headers = {**HEADERS, "Authorization": f"Bearer {self.api_key}"}
|
|
55
|
+
payload = {
|
|
56
|
+
"model": MODEL,
|
|
57
|
+
"messages": [
|
|
58
|
+
{"role": "system", "content": system},
|
|
59
|
+
{"role": "user", "content": prompt},
|
|
60
|
+
],
|
|
61
|
+
"temperature": 0.2,
|
|
62
|
+
"max_tokens": 4096,
|
|
63
|
+
"stream": True,
|
|
64
|
+
}
|
|
65
|
+
try:
|
|
66
|
+
with httpx.Client(timeout=60) as client:
|
|
67
|
+
with client.stream("POST", ENDPOINT, headers=headers, json=payload) as resp:
|
|
68
|
+
if resp.status_code == 429:
|
|
69
|
+
raise RateLimitError("OpenRouter rate limit exceeded")
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
for line in resp.iter_lines():
|
|
72
|
+
if line.startswith("data: "):
|
|
73
|
+
data_str = line[6:]
|
|
74
|
+
if data_str.strip() == "[DONE]":
|
|
75
|
+
break
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(data_str)
|
|
78
|
+
if data.get("choices") and data["choices"][0].get("delta", {}).get("content"):
|
|
79
|
+
yield data["choices"][0]["delta"]["content"]
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
continue
|
|
82
|
+
except httpx.ConnectError:
|
|
83
|
+
raise ProviderUnavailableError("Cannot connect to OpenRouter")
|
|
84
|
+
except httpx.TimeoutException:
|
|
85
|
+
raise ProviderUnavailableError("OpenRouter request timed out")
|
|
86
|
+
|
|
49
87
|
def is_available(self) -> bool:
|
|
50
88
|
if not self.api_key:
|
|
51
89
|
return False
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from librarian.commands import init, ask, do, why, undo, status
|
|
3
|
+
from librarian.commands import git_cmd
|
|
4
|
+
from librarian.commands import repl
|
|
5
|
+
from librarian.skills.loader import add_skill, list_skills
|
|
6
|
+
from librarian.utils.ui import print_banner, print_muted, print_warning, print_panel, console, INDIGO
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="librarian",
|
|
10
|
+
help="A CLI coding agent with persistent project memory.",
|
|
11
|
+
add_completion=False,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.callback(invoke_without_command=True)
|
|
16
|
+
def main(ctx: typer.Context):
|
|
17
|
+
if ctx.invoked_subcommand is None:
|
|
18
|
+
print_banner()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
app.command(name="init")(init.run)
|
|
22
|
+
app.command(name="ask")(ask.run)
|
|
23
|
+
app.command(name="do")(do.run)
|
|
24
|
+
app.command(name="why")(why.run)
|
|
25
|
+
app.command(name="undo")(undo.run)
|
|
26
|
+
app.command(name="status")(status.run)
|
|
27
|
+
app.command(name="repl")(repl.run)
|
|
28
|
+
|
|
29
|
+
git_app = typer.Typer(help="git operations")
|
|
30
|
+
git_app.command(name="commit")(git_cmd.commit)
|
|
31
|
+
git_app.command(name="push")(git_cmd.push)
|
|
32
|
+
git_app.command(name="diff")(git_cmd.diff)
|
|
33
|
+
git_app.command(name="status")(git_cmd.status)
|
|
34
|
+
app.add_typer(git_app, name="git")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _skill_add(name: str, file: str = None):
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
if file:
|
|
40
|
+
content = Path(file).read_text(encoding="utf-8")
|
|
41
|
+
else:
|
|
42
|
+
console.print(f"[bold {INDIGO}]enter skill conventions (Ctrl+D to finish):[/bold {INDIGO}]")
|
|
43
|
+
lines = []
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
lines.append(input())
|
|
47
|
+
except EOFError:
|
|
48
|
+
pass
|
|
49
|
+
content = "\n".join(lines)
|
|
50
|
+
add_skill(name, content)
|
|
51
|
+
print_muted(f" skill '{name}' added")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _skill_list():
|
|
55
|
+
skills = list_skills()
|
|
56
|
+
if not skills:
|
|
57
|
+
print_muted(" no skills found")
|
|
58
|
+
return
|
|
59
|
+
from rich.table import Table
|
|
60
|
+
table = Table(show_header=True, header_style=f"bold {INDIGO}")
|
|
61
|
+
table.add_column("name")
|
|
62
|
+
table.add_column("source")
|
|
63
|
+
for s in skills:
|
|
64
|
+
table.add_row(s["name"], s["source"])
|
|
65
|
+
console.print(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
skill_app = typer.Typer(help="manage custom skills")
|
|
69
|
+
skill_app.command(name="add")(_skill_add)
|
|
70
|
+
skill_app.command(name="list")(_skill_list)
|
|
71
|
+
app.add_typer(skill_app, name="skill")
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
app()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
2
3
|
from librarian.utils.ui import print_header, print_warning, print_panel, print_muted
|
|
3
4
|
from librarian.utils.token_tracker import tracker
|
|
4
5
|
from librarian.orchestrator.core import ask as ask_llm
|
|
@@ -18,7 +19,7 @@ def _check_api_keys():
|
|
|
18
19
|
return True
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def run(task: str):
|
|
22
|
+
def run(task: str, file: Optional[str] = None):
|
|
22
23
|
if not Path(".librarian").exists():
|
|
23
24
|
print_header("librarian ask")
|
|
24
25
|
print_warning("project not initialised — run 'librarian init' first")
|
|
@@ -30,13 +31,13 @@ def run(task: str):
|
|
|
30
31
|
print_header("librarian ask")
|
|
31
32
|
|
|
32
33
|
try:
|
|
33
|
-
chunks = retrieve(task, n_results=5)
|
|
34
|
+
chunks = retrieve(task, n_results=5, file_filter=file)
|
|
34
35
|
sources = []
|
|
35
36
|
for c in chunks:
|
|
36
37
|
meta = c["metadata"]
|
|
37
38
|
sources.append(f"{meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')}")
|
|
38
39
|
|
|
39
|
-
response, provider, tokens = ask_llm(task)
|
|
40
|
+
response, provider, tokens = ask_llm(task, file_filter=file)
|
|
40
41
|
tracker.add(provider, tokens)
|
|
41
42
|
print_panel(response, title="answer")
|
|
42
43
|
if sources:
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
from librarian.utils.ui import (
|
|
6
6
|
print_header, print_warning, print_success, print_muted,
|
|
7
7
|
print_panel, confirm_action, console, INDIGO, WARNING, SUCCESS,
|
|
8
|
+
print_diff,
|
|
8
9
|
)
|
|
9
10
|
from librarian.utils.token_tracker import tracker
|
|
10
11
|
from librarian.orchestrator.core import read_librarian_md, build_system_prompt
|
|
@@ -14,6 +15,7 @@ from librarian.memory import capsule, decision_log
|
|
|
14
15
|
from librarian.actions.file_ops import read_file, write_file, edit_file
|
|
15
16
|
from librarian.actions.shell_ops import run_command
|
|
16
17
|
from librarian.actions.safety import classify_action, RiskLevel
|
|
18
|
+
from librarian.actions.verify import verify_changes
|
|
17
19
|
from librarian.skills.loader import build_skill_context
|
|
18
20
|
|
|
19
21
|
DO_SYSTEM_PROMPT = """You are Librarian, a CLI coding agent. Respond ONLY with a JSON plan.
|
|
@@ -96,6 +98,28 @@ def _show_plan(plan: dict, task: str):
|
|
|
96
98
|
console.print(table)
|
|
97
99
|
|
|
98
100
|
|
|
101
|
+
def _preview_action(action: dict):
|
|
102
|
+
action_type = action.get("type")
|
|
103
|
+
if action_type == "edit_file":
|
|
104
|
+
path = Path(action["file"])
|
|
105
|
+
if path.exists():
|
|
106
|
+
content = read_file(action["file"])
|
|
107
|
+
if action["old_code"] in content:
|
|
108
|
+
new_content = content.replace(action["old_code"], action["new_code"], 1)
|
|
109
|
+
print_diff(action["file"], content, new_content)
|
|
110
|
+
elif action_type == "create_file":
|
|
111
|
+
path = Path(action["file"])
|
|
112
|
+
if path.exists():
|
|
113
|
+
old_content = read_file(action["file"])
|
|
114
|
+
print_diff(action["file"], old_content, action.get("content", ""))
|
|
115
|
+
else:
|
|
116
|
+
console.print(f"\n[bold {INDIGO}]new file:[/bold {INDIGO}] {action['file']}")
|
|
117
|
+
from rich.syntax import Syntax
|
|
118
|
+
content = action.get("content", "")
|
|
119
|
+
syntax = Syntax(content, Path(action["file"]).suffix.lstrip(".") or "text", theme="monokai")
|
|
120
|
+
console.print(Panel(syntax, border_style=INDIGO, padding=(0, 1)))
|
|
121
|
+
|
|
122
|
+
|
|
99
123
|
def _execute_action(action: dict) -> dict:
|
|
100
124
|
action_type = action.get("type")
|
|
101
125
|
if action_type == "edit_file":
|
|
@@ -155,7 +179,7 @@ def _check_api_keys():
|
|
|
155
179
|
return True
|
|
156
180
|
|
|
157
181
|
|
|
158
|
-
def run(task: str):
|
|
182
|
+
def run(task: str, file: str = None):
|
|
159
183
|
if not Path(".librarian").exists():
|
|
160
184
|
print_header("librarian do")
|
|
161
185
|
print_warning("project not initialised — run 'librarian init' first")
|
|
@@ -166,7 +190,7 @@ def run(task: str):
|
|
|
166
190
|
|
|
167
191
|
print_header("librarian do")
|
|
168
192
|
|
|
169
|
-
chunks = retrieve(task, n_results=7)
|
|
193
|
+
chunks = retrieve(task, n_results=7, file_filter=file)
|
|
170
194
|
conventions = read_librarian_md()
|
|
171
195
|
skill_ctx = build_skill_context()
|
|
172
196
|
|
|
@@ -192,6 +216,10 @@ def run(task: str):
|
|
|
192
216
|
|
|
193
217
|
_show_plan(plan, task)
|
|
194
218
|
|
|
219
|
+
print_muted("\n preview of changes:")
|
|
220
|
+
for action in plan.get("actions", []):
|
|
221
|
+
_preview_action(action)
|
|
222
|
+
|
|
195
223
|
if not confirm_action("proceed with execution?"):
|
|
196
224
|
print_muted(" cancelled")
|
|
197
225
|
return
|
|
@@ -216,6 +244,14 @@ def run(task: str):
|
|
|
216
244
|
except Exception as e:
|
|
217
245
|
print_warning(f"failed: {action.get('description', '?')} — {e}")
|
|
218
246
|
|
|
247
|
+
if results and files_changed:
|
|
248
|
+
print_muted("\n verifying changes...")
|
|
249
|
+
ok, msg = verify_changes()
|
|
250
|
+
if not ok:
|
|
251
|
+
print_warning(f"verification failed:\n{msg}")
|
|
252
|
+
else:
|
|
253
|
+
print_success("all checks passed")
|
|
254
|
+
|
|
219
255
|
decision_log.append({
|
|
220
256
|
"command": "do",
|
|
221
257
|
"task": task,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from librarian.utils.ui import print_header, print_warning, print_success, print_muted, print_panel
|
|
4
|
+
from librarian.actions.shell_ops import run_command, git_stage, git_commit, git_push
|
|
5
|
+
from librarian.actions.safety import classify_action, RiskLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_api_keys():
|
|
9
|
+
return True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def commit(message: str, files: Optional[list[str]] = None):
|
|
13
|
+
if not Path(".git").exists():
|
|
14
|
+
print_header("librarian commit")
|
|
15
|
+
print_warning("not a git repository")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
print_header("librarian commit")
|
|
19
|
+
|
|
20
|
+
if files:
|
|
21
|
+
git_stage(files)
|
|
22
|
+
else:
|
|
23
|
+
code, out, err = run_command("git add -A")
|
|
24
|
+
if code != 0:
|
|
25
|
+
print_warning(f"git add failed: {err}")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
git_commit(message)
|
|
30
|
+
print_success(f"committed: {message}")
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print_warning(f"commit failed: {e}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def push():
|
|
36
|
+
if not Path(".git").exists():
|
|
37
|
+
print_header("librarian push")
|
|
38
|
+
print_warning("not a git repository")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
print_header("librarian push")
|
|
42
|
+
|
|
43
|
+
code, out, err = run_command("git status --short")
|
|
44
|
+
if out.strip():
|
|
45
|
+
print_warning("uncommitted changes — commit first")
|
|
46
|
+
print_muted(out)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
git_push()
|
|
51
|
+
print_success("pushed to remote")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print_warning(f"push failed: {e}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def diff(file: Optional[str] = None):
|
|
57
|
+
if not Path(".git").exists():
|
|
58
|
+
print_header("librarian diff")
|
|
59
|
+
print_warning("not a git repository")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
print_header("librarian diff")
|
|
63
|
+
|
|
64
|
+
cmd = "git diff"
|
|
65
|
+
if file:
|
|
66
|
+
cmd += f" -- {file}"
|
|
67
|
+
|
|
68
|
+
code, out, err = run_command(cmd)
|
|
69
|
+
if code != 0:
|
|
70
|
+
print_warning(f"git diff failed: {err}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if not out.strip():
|
|
74
|
+
print_muted(" no changes")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
from rich.syntax import Syntax
|
|
78
|
+
from rich.panel import Panel
|
|
79
|
+
from librarian.utils.ui import console, INDIGO
|
|
80
|
+
|
|
81
|
+
syntax = Syntax(out, "diff", theme="monokai")
|
|
82
|
+
console.print(Panel(syntax, title="diff", border_style=INDIGO, padding=(0, 1)))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def status():
|
|
86
|
+
if not Path(".git").exists():
|
|
87
|
+
print_header("librarian status")
|
|
88
|
+
print_warning("not a git repository")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
print_header("librarian git status")
|
|
92
|
+
|
|
93
|
+
code, out, err = run_command("git status")
|
|
94
|
+
if code != 0:
|
|
95
|
+
print_warning(f"git status failed: {err}")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
print_panel(out, title="git status")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from librarian.utils.ui import print_header, print_warning, print_muted, print_panel, console, INDIGO, 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
|
+
from librarian.memory import session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _check_api_keys():
|
|
10
|
+
from librarian.utils.config import GROQ_API_KEY, OPENROUTER_API_KEY
|
|
11
|
+
if not GROQ_API_KEY and not OPENROUTER_API_KEY:
|
|
12
|
+
print_warning("no API keys found")
|
|
13
|
+
print_muted(" set at least one API key in .env file:")
|
|
14
|
+
print_muted("")
|
|
15
|
+
print_muted(" GROQ_API_KEY=gsk_... (free at console.groq.com)")
|
|
16
|
+
print_muted(" OPENROUTER_API_KEY=sk-or-... (free at openrouter.ai)")
|
|
17
|
+
print_muted("")
|
|
18
|
+
return False
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run():
|
|
23
|
+
if not Path(".librarian").exists():
|
|
24
|
+
print_header("librarian repl")
|
|
25
|
+
print_warning("project not initialised — run 'librarian init' first")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if not _check_api_keys():
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
print_header("librarian repl")
|
|
32
|
+
print_muted(" type 'exit' or 'quit' to leave, 'clear' to reset history")
|
|
33
|
+
print_muted("")
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
user_input = console.input(f"[bold {INDIGO}]you>[/bold {INDIGO}] ")
|
|
38
|
+
except (EOFError, KeyboardInterrupt):
|
|
39
|
+
print_muted("\n bye!")
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
user_input = user_input.strip()
|
|
43
|
+
if not user_input:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if user_input.lower() in ("exit", "quit", "q"):
|
|
47
|
+
print_muted(" bye!")
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if user_input.lower() == "clear":
|
|
51
|
+
session.clear_history()
|
|
52
|
+
print_muted(" history cleared")
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
chunks = retrieve(user_input, n_results=5)
|
|
57
|
+
sources = []
|
|
58
|
+
for c in chunks:
|
|
59
|
+
meta = c["metadata"]
|
|
60
|
+
sources.append(f"{meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')}")
|
|
61
|
+
|
|
62
|
+
response, provider, tokens = ask_llm(user_input)
|
|
63
|
+
tracker.add(provider, tokens)
|
|
64
|
+
|
|
65
|
+
print_panel(response, title="answer")
|
|
66
|
+
if sources:
|
|
67
|
+
print_muted(f" sources {', '.join(sources[:3])}")
|
|
68
|
+
print_muted(f" tokens {tokens} provider {provider}")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print_warning(f"error: {e}")
|
|
@@ -92,3 +92,12 @@ def _archive_low_confidence():
|
|
|
92
92
|
|
|
93
93
|
def get_all() -> list[dict]:
|
|
94
94
|
return _load()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_file_confidence(file_path: str) -> float:
|
|
98
|
+
capsules = _load()
|
|
99
|
+
relevant = [c for c in capsules if c.get("file") == file_path]
|
|
100
|
+
if not relevant:
|
|
101
|
+
return 1.0
|
|
102
|
+
avg = sum(c["confidence"] for c in relevant) / len(relevant)
|
|
103
|
+
return max(avg, 0.1)
|
|
@@ -3,6 +3,7 @@ import chromadb
|
|
|
3
3
|
from sentence_transformers import SentenceTransformer
|
|
4
4
|
from librarian.utils.config import CHROMA_PERSIST_DIR, EMBED_MODEL
|
|
5
5
|
from librarian.memory.indexer import _sanitize_collection_name
|
|
6
|
+
from librarian.memory import capsule
|
|
6
7
|
|
|
7
8
|
_model = None
|
|
8
9
|
_client = None
|
|
@@ -22,7 +23,7 @@ def _get_client():
|
|
|
22
23
|
return _client
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def retrieve(query: str, n_results: int = 5) -> list[dict]:
|
|
26
|
+
def retrieve(query: str, n_results: int = 5, file_filter: str = None) -> list[dict]:
|
|
26
27
|
model = _get_model()
|
|
27
28
|
client = _get_client()
|
|
28
29
|
project_name = _sanitize_collection_name(os.path.basename(os.getcwd()))
|
|
@@ -33,10 +34,16 @@ def retrieve(query: str, n_results: int = 5) -> list[dict]:
|
|
|
33
34
|
return []
|
|
34
35
|
|
|
35
36
|
query_embedding = model.encode([query]).tolist()
|
|
37
|
+
|
|
38
|
+
where_filter = None
|
|
39
|
+
if file_filter:
|
|
40
|
+
where_filter = {"file_path": {"$contains": file_filter}}
|
|
41
|
+
|
|
36
42
|
results = collection.query(
|
|
37
43
|
query_embeddings=query_embedding,
|
|
38
44
|
n_results=n_results,
|
|
39
45
|
include=["documents", "metadatas", "distances"],
|
|
46
|
+
where=where_filter,
|
|
40
47
|
)
|
|
41
48
|
|
|
42
49
|
if not results.get("documents") or not results["documents"][0]:
|
|
@@ -50,10 +57,12 @@ def retrieve(query: str, n_results: int = 5) -> list[dict]:
|
|
|
50
57
|
):
|
|
51
58
|
if dist > 2.5:
|
|
52
59
|
continue
|
|
60
|
+
file_conf = capsule.get_file_confidence(meta.get("file_path", ""))
|
|
61
|
+
adjusted_dist = dist / file_conf
|
|
53
62
|
chunks.append({
|
|
54
63
|
"content": doc,
|
|
55
64
|
"metadata": meta,
|
|
56
|
-
"distance":
|
|
65
|
+
"distance": adjusted_dist,
|
|
57
66
|
})
|
|
58
67
|
|
|
59
68
|
if chunks and sum(c["distance"] for c in chunks) / len(chunks) > 2.0:
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
SESSION_FILE = ".librarian/session.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load() -> dict:
|
|
9
|
+
path = Path(SESSION_FILE)
|
|
10
|
+
if not path.exists():
|
|
11
|
+
return {"history": [], "created_at": datetime.now(timezone.utc).isoformat()}
|
|
12
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _save(session: dict):
|
|
16
|
+
Path(SESSION_FILE).write_text(json.dumps(session, indent=2), encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_message(role: str, content: str):
|
|
20
|
+
session = _load()
|
|
21
|
+
session["history"].append({
|
|
22
|
+
"role": role,
|
|
23
|
+
"content": content,
|
|
24
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
25
|
+
})
|
|
26
|
+
if len(session["history"]) > 20:
|
|
27
|
+
session["history"] = session["history"][-20:]
|
|
28
|
+
_save(session)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_history(max_messages: int = 10) -> list[dict]:
|
|
32
|
+
session = _load()
|
|
33
|
+
return session["history"][-max_messages:]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clear_history():
|
|
37
|
+
_save({"history": [], "created_at": datetime.now(timezone.utc).isoformat()})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_history(max_messages: int = 10) -> str:
|
|
41
|
+
history = get_history(max_messages)
|
|
42
|
+
if not history:
|
|
43
|
+
return ""
|
|
44
|
+
parts = []
|
|
45
|
+
for msg in history:
|
|
46
|
+
role = "user" if msg["role"] == "user" else "assistant"
|
|
47
|
+
parts.append(f"[{role}]: {msg['content'][:500]}")
|
|
48
|
+
return "\n\n".join(parts)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from librarian.orchestrator.router import get_response
|
|
3
3
|
from librarian.memory.retriever import retrieve
|
|
4
|
+
from librarian.memory import session
|
|
4
5
|
from librarian.skills.loader import build_skill_context
|
|
5
6
|
|
|
6
7
|
|
|
@@ -29,12 +30,12 @@ def read_librarian_md() -> str:
|
|
|
29
30
|
return "No project conventions file found."
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def ask(question: str) -> tuple[str, str, int]:
|
|
33
|
+
def ask(question: str, file_filter: str = None) -> tuple[str, str, int]:
|
|
33
34
|
conventions = read_librarian_md()
|
|
34
35
|
skill_ctx = build_skill_context()
|
|
35
36
|
system = build_system_prompt(conventions, skill_ctx)
|
|
36
37
|
|
|
37
|
-
chunks = retrieve(question, n_results=5)
|
|
38
|
+
chunks = retrieve(question, n_results=5, file_filter=file_filter)
|
|
38
39
|
context = ""
|
|
39
40
|
if chunks:
|
|
40
41
|
parts = []
|
|
@@ -43,5 +44,16 @@ def ask(question: str) -> tuple[str, str, int]:
|
|
|
43
44
|
parts.append(f"--- {meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')} ---\n{c['content']}")
|
|
44
45
|
context = "\n\n".join(parts)
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
history = session.format_history(max_messages=6)
|
|
48
|
+
prompt_parts = []
|
|
49
|
+
if context:
|
|
50
|
+
prompt_parts.append(f"Relevant code context:\n{context}")
|
|
51
|
+
if history:
|
|
52
|
+
prompt_parts.append(f"Previous conversation:\n{history}")
|
|
53
|
+
prompt_parts.append(f"Question: {question}")
|
|
54
|
+
prompt = "\n\n".join(prompt_parts)
|
|
55
|
+
|
|
56
|
+
session.add_message("user", question)
|
|
57
|
+
response, provider, tokens = get_response(system, prompt)
|
|
58
|
+
session.add_message("assistant", response)
|
|
59
|
+
return response, provider, tokens
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from typing import Iterator
|
|
1
2
|
from librarian.adapter.groq_adapter import GroqAdapter
|
|
2
3
|
from librarian.adapter.openrouter_adapter import OpenRouterAdapter
|
|
3
4
|
from librarian.exceptions import RateLimitError, ProviderUnavailableError
|
|
@@ -15,3 +16,15 @@ def get_response(system: str, prompt: str) -> tuple[str, str, int]:
|
|
|
15
16
|
log_warning(f"{e} — switching to OpenRouter")
|
|
16
17
|
response = fallback.complete(system, prompt)
|
|
17
18
|
return response, "openrouter", fallback.tokens_used
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_response_stream(system: str, prompt: str) -> tuple[Iterator[str], str]:
|
|
22
|
+
primary = GroqAdapter()
|
|
23
|
+
fallback = OpenRouterAdapter()
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
_ = primary.complete_stream(system, prompt)
|
|
27
|
+
return primary.complete_stream(system, prompt), "groq"
|
|
28
|
+
except (RateLimitError, ProviderUnavailableError) as e:
|
|
29
|
+
log_warning(f"{e} — switching to OpenRouter")
|
|
30
|
+
return fallback.complete_stream(system, prompt), "openrouter"
|
|
@@ -6,6 +6,7 @@ import functools
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
SKILLS_DIR = Path(__file__).parent / "bundled"
|
|
9
|
+
CUSTOM_SKILLS_DIR = Path(".librarian/skills")
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@functools.lru_cache(maxsize=1)
|
|
@@ -82,6 +83,12 @@ def _detect_project_type() -> list[str]:
|
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
def load_skill(skill_name: str) -> str | None:
|
|
86
|
+
custom_dir = CUSTOM_SKILLS_DIR / skill_name
|
|
87
|
+
if custom_dir.exists():
|
|
88
|
+
conventions_file = custom_dir / "conventions.md"
|
|
89
|
+
if conventions_file.exists():
|
|
90
|
+
return conventions_file.read_text(encoding="utf-8")
|
|
91
|
+
|
|
85
92
|
skill_dir = SKILLS_DIR / skill_name
|
|
86
93
|
if not skill_dir.exists():
|
|
87
94
|
return None
|
|
@@ -91,6 +98,32 @@ def load_skill(skill_name: str) -> str | None:
|
|
|
91
98
|
return None
|
|
92
99
|
|
|
93
100
|
|
|
101
|
+
def add_skill(skill_name: str, content: str):
|
|
102
|
+
CUSTOM_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
skill_dir = CUSTOM_SKILLS_DIR / skill_name
|
|
104
|
+
skill_dir.mkdir(exist_ok=True)
|
|
105
|
+
(skill_dir / "conventions.md").write_text(content, encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_skills() -> list[dict]:
|
|
109
|
+
skills = []
|
|
110
|
+
|
|
111
|
+
for skill_dir in SKILLS_DIR.iterdir():
|
|
112
|
+
if skill_dir.is_dir():
|
|
113
|
+
conventions = skill_dir / "conventions.md"
|
|
114
|
+
if conventions.exists():
|
|
115
|
+
skills.append({"name": skill_dir.name, "source": "bundled"})
|
|
116
|
+
|
|
117
|
+
if CUSTOM_SKILLS_DIR.exists():
|
|
118
|
+
for skill_dir in CUSTOM_SKILLS_DIR.iterdir():
|
|
119
|
+
if skill_dir.is_dir():
|
|
120
|
+
conventions = skill_dir / "conventions.md"
|
|
121
|
+
if conventions.exists():
|
|
122
|
+
skills.append({"name": skill_dir.name, "source": "custom"})
|
|
123
|
+
|
|
124
|
+
return skills
|
|
125
|
+
|
|
126
|
+
|
|
94
127
|
def get_relevant_skills() -> list[str]:
|
|
95
128
|
return _detect_project_type()
|
|
96
129
|
|
|
@@ -13,3 +13,14 @@ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
|
|
13
13
|
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
|
14
14
|
CHROMA_PERSIST_DIR = os.getenv("CHROMA_PERSIST_DIR", ".librarian/memory")
|
|
15
15
|
EMBED_MODEL = os.getenv("EMBED_MODEL", "all-MiniLM-L6-v2")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_toml_defaults():
|
|
19
|
+
try:
|
|
20
|
+
from librarian.utils.toml_config import get_config_value
|
|
21
|
+
return get_config_value("model", EMBED_MODEL)
|
|
22
|
+
except Exception:
|
|
23
|
+
return EMBED_MODEL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
EMBED_MODEL = _load_toml_defaults()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
CONFIG_FILE = "librarian.toml"
|
|
6
|
+
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"model": "all-MiniLM-L6-v2",
|
|
9
|
+
"max_results": 5,
|
|
10
|
+
"distance_threshold": 2.5,
|
|
11
|
+
"auto_verify": True,
|
|
12
|
+
"max_history": 20,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> dict:
|
|
17
|
+
config = dict(DEFAULTS)
|
|
18
|
+
|
|
19
|
+
config_path = Path(CONFIG_FILE)
|
|
20
|
+
if config_path.exists():
|
|
21
|
+
with open(config_path, "rb") as f:
|
|
22
|
+
user_config = tomllib.load(f)
|
|
23
|
+
if "librarian" in user_config:
|
|
24
|
+
config.update(user_config["librarian"])
|
|
25
|
+
|
|
26
|
+
if os.getenv("GROQ_API_KEY"):
|
|
27
|
+
config["provider"] = "groq"
|
|
28
|
+
elif os.getenv("OPENROUTER_API_KEY"):
|
|
29
|
+
config["provider"] = "openrouter"
|
|
30
|
+
|
|
31
|
+
return config
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_config_value(key: str, default=None):
|
|
35
|
+
config = load_config()
|
|
36
|
+
return config.get(key, default)
|
|
@@ -95,3 +95,32 @@ def spinner(description: str):
|
|
|
95
95
|
TextColumn(f"[{MUTED}]{description}[/{MUTED}]"),
|
|
96
96
|
transient=True,
|
|
97
97
|
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def print_stream(iterator, style: str = ""):
|
|
101
|
+
from rich.live import Live
|
|
102
|
+
from rich.text import Text
|
|
103
|
+
|
|
104
|
+
text = Text("", style=style)
|
|
105
|
+
with Live(text, console=console, refresh_per_second=10) as live:
|
|
106
|
+
for token in iterator:
|
|
107
|
+
text.append(token)
|
|
108
|
+
live.refresh()
|
|
109
|
+
return str(text)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def print_diff(file_path: str, old_content: str, new_content: str):
|
|
113
|
+
from rich.syntax import Syntax
|
|
114
|
+
import difflib
|
|
115
|
+
|
|
116
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
117
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
118
|
+
diff = list(difflib.unified_diff(old_lines, new_lines, fromfile=f"a/{file_path}", tofile=f"b/{file_path}"))
|
|
119
|
+
|
|
120
|
+
if not diff:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
diff_text = "".join(diff)
|
|
124
|
+
console.print(f"\n[bold {INDIGO}]diff:[/bold {INDIGO}] {file_path}")
|
|
125
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
126
|
+
console.print(Panel(syntax, border_style=INDIGO, padding=(0, 1)))
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# Plan: Publish to PyPI for `pip install -g librarian-code`
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
Make the package installable globally via `pip install -g librarian-code`.
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Phase 1: Fix Package Metadata
|
|
9
|
-
|
|
10
|
-
### 1. Update `pyproject.toml`
|
|
11
|
-
Add missing fields: `readme`, `license`, `authors`, `classifiers`, `urls`, and build artifacts for bundled skills.
|
|
12
|
-
|
|
13
|
-
### 2. Add `__version__` to `librarian/__init__.py`
|
|
14
|
-
```python
|
|
15
|
-
__version__ = "0.1.0"
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
### 3. Fix `README.md`
|
|
19
|
-
Remove `.env.example` reference (file doesn't exist).
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## Phase 2: Set Up PyPI Account
|
|
24
|
-
|
|
25
|
-
1. Go to https://pypi.org/account/register/
|
|
26
|
-
2. Create account (use a dedicated email)
|
|
27
|
-
3. Verify email
|
|
28
|
-
|
|
29
|
-
### Generate API Token
|
|
30
|
-
1. Go to https://pypi.org/manage/account/token/
|
|
31
|
-
2. Click "Add API token"
|
|
32
|
-
3. Name: `librarian-code-publish`
|
|
33
|
-
4. Scope: "Entire account" (or project-specific after first upload)
|
|
34
|
-
5. Copy token — **it won't be shown again**
|
|
35
|
-
|
|
36
|
-
### Store Token Locally
|
|
37
|
-
```bash
|
|
38
|
-
pip install twine
|
|
39
|
-
# Create ~/.pypirc (Windows: %USERPROFILE%\.pypirc)
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Contents:
|
|
43
|
-
```ini
|
|
44
|
-
[pypi]
|
|
45
|
-
username = __token__
|
|
46
|
-
password = pypi-YOUR_TOKEN_HERE
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## Phase 3: Build & Publish
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
# 1. Install build tools
|
|
55
|
-
pip install build twine
|
|
56
|
-
|
|
57
|
-
# 2. Clean old builds
|
|
58
|
-
rm -rf dist/ build/ *.egg-info
|
|
59
|
-
|
|
60
|
-
# 3. Build
|
|
61
|
-
python -m build
|
|
62
|
-
|
|
63
|
-
# 4. Verify package contents
|
|
64
|
-
twine check dist/*
|
|
65
|
-
|
|
66
|
-
# 5. Upload to PyPI
|
|
67
|
-
twine upload dist/*
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
---
|
|
71
|
-
|
|
72
|
-
## Phase 4: Verify
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
# Test install from PyPI
|
|
76
|
-
pip install -g librarian-code
|
|
77
|
-
|
|
78
|
-
# Should work
|
|
79
|
-
librarian --version
|
|
80
|
-
librarian --help
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
## Files to Modify
|
|
86
|
-
- `pyproject.toml`
|
|
87
|
-
- `librarian/__init__.py`
|
|
88
|
-
- `README.md`
|
|
89
|
-
|
|
90
|
-
## Verification
|
|
91
|
-
1. `python -m build` — produces `.whl` + `.tar.gz`
|
|
92
|
-
2. `twine check dist/*` — passes
|
|
93
|
-
3. `pip install dist/*.whl` — installs correctly
|
|
94
|
-
4. `librarian --version` — prints `0.1.0`
|
|
95
|
-
5. Bundled skills `.md` files included in wheel
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/api-design/conventions.md
RENAMED
|
File without changes
|
{librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/python/conventions.md
RENAMED
|
File without changes
|
|
File without changes
|
{librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/web-dev/conventions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|