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.
Files changed (58) hide show
  1. librarian_code-0.2.0/.mimocode/plans/1782009063038-gentle-garden.md +88 -0
  2. {librarian_code-0.1.0 → librarian_code-0.2.0}/PKG-INFO +1 -1
  3. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/__init__.py +1 -1
  4. librarian_code-0.2.0/librarian/actions/verify.py +40 -0
  5. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/base.py +5 -0
  6. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/groq_adapter.py +23 -0
  7. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/openrouter_adapter.py +38 -0
  8. librarian_code-0.2.0/librarian/cli.py +74 -0
  9. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/ask.py +4 -3
  10. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/do.py +38 -2
  11. librarian_code-0.2.0/librarian/commands/git_cmd.py +98 -0
  12. librarian_code-0.2.0/librarian/commands/repl.py +70 -0
  13. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/capsule.py +9 -0
  14. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/retriever.py +11 -2
  15. librarian_code-0.2.0/librarian/memory/session.py +48 -0
  16. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/core.py +16 -4
  17. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/router.py +13 -0
  18. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/loader.py +33 -0
  19. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/config.py +11 -0
  20. librarian_code-0.2.0/librarian/utils/toml_config.py +36 -0
  21. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/ui.py +29 -0
  22. {librarian_code-0.1.0 → librarian_code-0.2.0}/pyproject.toml +1 -1
  23. librarian_code-0.1.0/.mimocode/plans/1782009063038-gentle-garden.md +0 -95
  24. librarian_code-0.1.0/librarian/cli.py +0 -26
  25. {librarian_code-0.1.0 → librarian_code-0.2.0}/.gitignore +0 -0
  26. {librarian_code-0.1.0 → librarian_code-0.2.0}/LICENSE.md +0 -0
  27. {librarian_code-0.1.0 → librarian_code-0.2.0}/README.md +0 -0
  28. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/__main__.py +0 -0
  29. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/__init__.py +0 -0
  30. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/file_ops.py +0 -0
  31. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/safety.py +0 -0
  32. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/actions/shell_ops.py +0 -0
  33. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/adapter/__init__.py +0 -0
  34. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/__init__.py +0 -0
  35. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/init.py +0 -0
  36. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/status.py +0 -0
  37. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/undo.py +0 -0
  38. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/commands/why.py +0 -0
  39. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/exceptions.py +0 -0
  40. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/__init__.py +0 -0
  41. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/chunker.py +0 -0
  42. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/decision_log.py +0 -0
  43. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/memory/indexer.py +0 -0
  44. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/orchestrator/__init__.py +0 -0
  45. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/__init__.py +0 -0
  46. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/__init__.py +0 -0
  47. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/api-design/conventions.md +0 -0
  48. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/python/conventions.md +0 -0
  49. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/react/conventions.md +0 -0
  50. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/skills/bundled/web-dev/conventions.md +0 -0
  51. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/__init__.py +0 -0
  52. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/logger.py +0 -0
  53. {librarian_code-0.1.0 → librarian_code-0.2.0}/librarian/utils/token_tracker.py +0 -0
  54. {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/__init__.py +0 -0
  55. {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_actions.py +0 -0
  56. {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_adapter.py +0 -0
  57. {librarian_code-0.1.0 → librarian_code-0.2.0}/tests/test_commands.py +0 -0
  58. {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.1.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
@@ -1,3 +1,3 @@
1
1
  """Librarian — a CLI coding agent with persistent project memory."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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": dist,
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
- prompt = f"Relevant code context:\n{context}\n\nQuestion: {question}" if context else question
47
- return get_response(system, prompt)
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "librarian-code"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A local-first CLI coding agent with persistent project memory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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