akitallm 0.1.1__py3-none-any.whl → 1.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.
akita/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "1.1.0"
akita/cli/main.py CHANGED
@@ -1,13 +1,18 @@
1
1
  import typer
2
+ from typing import Optional, List, Dict, Any
2
3
  from rich.console import Console
3
4
  from rich.panel import Panel
4
5
  from akita.reasoning.engine import ReasoningEngine
6
+ from akita.core.indexing import CodeIndexer
5
7
  from akita.models.base import get_model
6
8
  from akita.core.config import load_config, save_config, reset_config, CONFIG_FILE
7
9
  from rich.table import Table
8
10
  from rich.markdown import Markdown
9
11
  from rich.syntax import Syntax
10
12
  from dotenv import load_dotenv
13
+ from akita.tools.diff import DiffApplier
14
+ from akita.tools.git import GitTool
15
+ from akita.core.providers import detect_provider
11
16
 
12
17
  # Load environment variables from .env file
13
18
  load_dotenv()
@@ -40,27 +45,65 @@ def main(
40
45
 
41
46
  def run_onboarding():
42
47
  console.print(Panel(
43
- "[bold cyan]AkitaLLM[/]\n\n[italic]Understanding the internals...[/]",
48
+ "[bold cyan]AkitaLLM Configuration[/]\n\n[italic]API-first setup...[/]",
44
49
  title="Onboarding"
45
50
  ))
46
51
 
47
- console.print("1) Use default project model (GPT-4o Mini)")
48
- console.print("2) Configure my own model")
52
+ api_key = typer.prompt("🔑 Paste your API Key (or type 'ollama' for local)", hide_input=False)
49
53
 
50
- choice = typer.prompt("\nChoose an option", type=int, default=1)
54
+ provider = detect_provider(api_key)
55
+ if not provider:
56
+ console.print("[bold red]❌ Could not detect provider from the given key.[/]")
57
+ console.print("Make sure you are using a valid OpenAI (sk-...) or Anthropic (sk-ant-...) key.")
58
+ raise typer.Abort()
59
+
60
+ console.print(f"[bold green]✅ Detected Provider:[/] {provider.name.upper()}")
61
+
62
+ with console.status(f"[bold blue]Consulting {provider.name} API for available models..."):
63
+ try:
64
+ models = provider.list_models(api_key)
65
+ except Exception as e:
66
+ console.print(f"[bold red]❌ Failed to list models:[/] {e}")
67
+ raise typer.Abort()
51
68
 
52
- if choice == 1:
53
- config = {"model": {"provider": "openai", "name": "gpt-4o-mini"}}
54
- save_config(config)
55
- console.print("[bold green]✅ Default model (GPT-4o Mini) selected and saved![/]")
69
+ if not models:
70
+ console.print("[bold yellow]⚠️ No models found for this provider.[/]")
71
+ raise typer.Abort()
72
+
73
+ console.print("\n[bold]Select a model:[/]")
74
+ for i, model in enumerate(models):
75
+ name_display = f" ({model.name})" if model.name else ""
76
+ console.print(f"{i+1}) [cyan]{model.id}[/]{name_display}")
77
+
78
+ choice = typer.prompt("\nChoose a model number", type=int, default=1)
79
+ if 1 <= choice <= len(models):
80
+ selected_model = models[choice-1].id
56
81
  else:
57
- provider = typer.prompt("Enter model provider (e.g., openai, ollama, anthropic)", default="openai")
58
- name = typer.prompt("Enter model name (e.g., gpt-4o, llama3, claude-3-opus)", default="gpt-4o-mini")
59
- config = {"model": {"provider": provider, "name": name}}
60
- save_config(config)
61
- console.print(f"[bold green]✅ Model configured: {provider}/{name}[/]")
82
+ console.print("[bold red]Invalid choice.[/]")
83
+ raise typer.Abort()
84
+
85
+ # Determine if we should save the key or use an env ref
86
+ use_env = typer.confirm("Would you like to use an environment variable for the API key? (Recommended)", default=True)
62
87
 
63
- console.print("\n[dim]Configuration saved at ~/.akita/config.toml[/]\n")
88
+ final_key_ref = api_key
89
+ if use_env and provider.name != "ollama":
90
+ env_var_name = f"{provider.name.upper()}_API_KEY"
91
+ console.print(f"[dim]Please ensure you set [bold]{env_var_name}[/] in your .env or shell.[/]")
92
+ final_key_ref = f"env:{env_var_name}"
93
+
94
+ config = {
95
+ "model": {
96
+ "provider": provider.name,
97
+ "name": selected_model,
98
+ "api_key": final_key_ref
99
+ }
100
+ }
101
+
102
+ save_config(config)
103
+ console.print(f"\n[bold green]✨ Configuration saved![/]")
104
+ console.print(f"Model: [bold]{selected_model}[/]")
105
+ console.print(f"Key reference: [dim]{final_key_ref}[/]")
106
+ console.print("\n[dim]Configuration stored at ~/.akita/config.toml[/]\n")
64
107
 
65
108
  @app.command()
66
109
  def review(
@@ -119,27 +162,53 @@ def review(
119
162
  @app.command()
120
163
  def solve(
121
164
  query: str,
165
+ interactive: bool = typer.Option(False, "--interactive", "-i", help="Run in interactive mode to refine the solution."),
166
+ trace: bool = typer.Option(False, "--trace", help="Show the internal reasoning trace."),
122
167
  dry_run: bool = typer.Option(False, "--dry-run", help="Run in dry-run mode.")
123
168
  ):
124
169
  """
125
- Generate a solution for the given query.
170
+ Generate and apply a solution for the given query.
126
171
  """
127
172
  model = get_model()
128
173
  engine = ReasoningEngine(model)
129
174
  console.print(Panel(f"[bold blue]Akita[/] is thinking about: [italic]{query}[/]", title="Solve Mode"))
130
175
 
176
+ current_query = query
177
+ session = None
178
+
131
179
  try:
132
- diff_output = engine.run_solve(query)
133
-
134
- console.print(Panel("[bold green]Suggested Code Changes (Unified Diff):[/]"))
135
- syntax = Syntax(diff_output, "diff", theme="monokai", line_numbers=True)
136
- console.print(syntax)
137
-
180
+ while True:
181
+ diff_output = engine.run_solve(current_query, session=session)
182
+ session = engine.session
183
+
184
+ if trace:
185
+ console.print(Panel(str(engine.trace), title="[bold cyan]Reasoning Trace[/]", border_style="cyan"))
186
+ console.print(Panel("[bold green]Suggested Code Changes (Unified Diff):[/]"))
187
+ syntax = Syntax(diff_output, "diff", theme="monokai", line_numbers=True)
188
+ console.print(syntax)
189
+
190
+ if interactive:
191
+ action = typer.prompt("\n[A]pprove, [R]efine with feedback, or [C]ancel?", default="A").upper()
192
+ if action == "A":
193
+ break
194
+ elif action == "R":
195
+ current_query = typer.prompt("Enter your feedback/refinement")
196
+ continue
197
+ else:
198
+ console.print("[yellow]Operation cancelled.[/]")
199
+ return
200
+ else:
201
+ break
202
+
138
203
  if not dry_run:
139
204
  confirm = typer.confirm("\nDo you want to apply these changes?")
140
205
  if confirm:
141
- console.print("[bold yellow]Applying changes... (DiffApplier to be implemented next)[/]")
142
- # We will implement DiffApplier in the next step
206
+ console.print("[bold yellow]🚀 Applying changes...[/]")
207
+ success = DiffApplier.apply_unified_diff(diff_output)
208
+ if success:
209
+ console.print("[bold green]✅ Changes applied successfully![/]")
210
+ else:
211
+ console.print("[bold red]❌ Failed to apply changes.[/]")
143
212
  else:
144
213
  console.print("[bold yellow]Changes discarded.[/]")
145
214
  except Exception as e:
@@ -165,12 +234,52 @@ def plan(
165
234
  console.print(f"[bold red]Planning failed:[/] {e}")
166
235
  raise typer.Exit(code=1)
167
236
 
237
+ @app.command()
238
+ def clone(
239
+ url: str = typer.Argument(..., help="Git repository URL to clone."),
240
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Specific branch to clone."),
241
+ depth: Optional[int] = typer.Option(None, "--depth", "-d", help="Create a shallow clone with a history truncated to the specified number of commits.")
242
+ ):
243
+ """
244
+ Clone a remote Git repository into the Akita workspace (~/.akita/repos/).
245
+ """
246
+ console.print(Panel(f"🌐 [bold blue]Akita[/] is cloning: [yellow]{url}[/]", title="Clone Mode"))
247
+
248
+ try:
249
+ with console.status("[bold green]Cloning repository..."):
250
+ local_path = GitTool.clone_repo(url, branch=branch, depth=depth)
251
+
252
+ console.print(f"\n[bold green]✅ Repository cloned successfully![/]")
253
+ console.print(f"📍 Local path: [cyan]{local_path}[/]")
254
+ except FileExistsError as e:
255
+ console.print(f"[bold yellow]⚠️ {e}[/]")
256
+ except Exception as e:
257
+ console.print(f"[bold red]❌ Clone failed:[/] {e}")
258
+ raise typer.Exit(code=1)
259
+
260
+ @app.command()
261
+ def index(
262
+ path: str = typer.Argument(".", help="Path to index for RAG.")
263
+ ):
264
+ """
265
+ Build a local vector index (RAG) for the project.
266
+ """
267
+ console.print(Panel(f"🔍 [bold blue]Akita[/] is indexing: [yellow]{path}[/]", title="Index Mode"))
268
+ try:
269
+ indexer = CodeIndexer(path)
270
+ with console.status("[bold green]Indexing project files..."):
271
+ indexer.index_project()
272
+ console.print("[bold green]✅ Indexing complete! Semantic search is now active.[/]")
273
+ except Exception as e:
274
+ console.print(f"[bold red]Indexing failed:[/] {e}")
275
+ raise typer.Exit(code=1)
276
+
168
277
  @app.command()
169
278
  def test():
170
279
  """
171
280
  Run automated tests in the project.
172
281
  """
173
- console.print(Panel("🐶 [bold blue]Akita[/] is running tests...", title="Test Mode"))
282
+ console.print(Panel("[bold blue]Akita[/] is running tests...", title="Test Mode"))
174
283
  from akita.tools.base import ShellTools
175
284
  result = ShellTools.execute("pytest")
176
285
  if result.success:
@@ -180,6 +289,26 @@ def test():
180
289
  console.print("[bold red]Tests failed![/]")
181
290
  console.print(result.error or result.output)
182
291
 
292
+ @app.command()
293
+ def docs():
294
+ """
295
+ Start the local documentation server.
296
+ """
297
+ import subprocess
298
+ import sys
299
+
300
+ console.print(Panel("[bold blue]Akita[/] Documentation", title="Docs Mode"))
301
+ console.print("[dim]Starting MkDocs server...[/]")
302
+ console.print("[bold green]Open your browser at: http://127.0.0.1:8000[/]")
303
+
304
+ try:
305
+ subprocess.run([sys.executable, "-m", "mkdocs", "serve"], check=True)
306
+ except FileNotFoundError:
307
+ console.print("[red]MkDocs not found. Install it with: pip install mkdocs-material[/]")
308
+ raise typer.Exit(code=1)
309
+ except KeyboardInterrupt:
310
+ console.print("[yellow]Documentation server stopped.[/]")
311
+
183
312
  # Config Command Group
184
313
  config_app = typer.Typer(help="Manage AkitaLLM configuration.")
185
314
  app.add_typer(config_app, name="config")
@@ -0,0 +1,77 @@
1
+ import tree_sitter_python as tspython
2
+ from tree_sitter import Language, Parser
3
+ import pathlib
4
+ from typing import List, Dict, Any, Optional
5
+
6
+ class ASTParser:
7
+ def __init__(self):
8
+ self.language = Language(tspython.language())
9
+ self.parser = Parser(self.language)
10
+
11
+ def parse_file(self, file_path: str) -> Optional[Any]:
12
+ path = pathlib.Path(file_path)
13
+ if not path.exists():
14
+ return None
15
+
16
+ with open(path, "rb") as f:
17
+ content = f.read()
18
+
19
+ return self.parser.parse(content)
20
+
21
+ def get_definitions(self, file_path: str) -> List[Dict[str, Any]]:
22
+ """Extract classes and functions with their line ranges using recursive traversal."""
23
+ tree = self.parse_file(file_path)
24
+ if not tree:
25
+ return []
26
+
27
+ with open(file_path, "rb") as f:
28
+ content = f.read()
29
+
30
+ definitions = []
31
+
32
+ def explore(node):
33
+ # Check for definitions
34
+ if node.type in ["class_definition", "function_definition", "decorated_definition"]:
35
+ # Find name
36
+ name_node = node.child_by_field_name("name")
37
+ if not name_node and node.type == "decorated_definition":
38
+ # For decorated definitions, the name is in the class/function child
39
+ inner = node.child_by_field_name("definition")
40
+ if inner:
41
+ name_node = inner.child_by_field_name("name")
42
+
43
+ name = content[name_node.start_byte:name_node.end_byte].decode("utf-8") if name_node else "anonymous"
44
+
45
+ # Docstring extraction
46
+ docstring = None
47
+ body = node.child_by_field_name("body")
48
+ if body and body.children:
49
+ for stmt in body.children:
50
+ if stmt.type == "expression_statement":
51
+ child = stmt.children[0]
52
+ if child.type == "string":
53
+ docstring = content[child.start_byte:child.end_byte].decode("utf-8").strip('"\' \n')
54
+ break # Only first statement
55
+
56
+ definitions.append({
57
+ "name": name,
58
+ "type": "class" if "class" in node.type else "function",
59
+ "start_line": node.start_point[0] + 1,
60
+ "end_line": node.end_point[0] + 1,
61
+ "docstring": docstring
62
+ })
63
+
64
+ # Always explore children regardless of current node type
65
+ for child in node.children:
66
+ explore(child)
67
+
68
+ explore(tree.root_node)
69
+ return definitions
70
+
71
+ def get_source_segment(self, file_path: str, start_line: int, end_line: int) -> str:
72
+ """Extract a segment of code from a file by line numbers."""
73
+ with open(file_path, "r", encoding="utf-8") as f:
74
+ lines = f.readlines()
75
+
76
+ # Lines are 1-indexed in our definitions, but 0-indexed in the list
77
+ return "".join(lines[start_line-1 : end_line])
akita/core/config.py CHANGED
@@ -43,9 +43,19 @@ def reset_config():
43
43
  if CONFIG_FILE.exists():
44
44
  CONFIG_FILE.unlink()
45
45
 
46
+ def resolve_config_value(value: Any) -> Any:
47
+ """
48
+ Resolves values like 'env:VAR_NAME' to their environment variable content.
49
+ """
50
+ if isinstance(value, str) and value.startswith("env:"):
51
+ env_var = value[4:]
52
+ return os.getenv(env_var, value)
53
+ return value
54
+
46
55
  def get_config_value(section: str, key: str, default: Any = None) -> Any:
47
- """Get a specific value from the config."""
56
+ """Get a specific value from the config and resolve env refs."""
48
57
  config = load_config()
49
58
  if not config:
50
59
  return default
51
- return config.get(section, {}).get(key, default)
60
+ val = config.get(section, {}).get(key, default)
61
+ return resolve_config_value(val)
akita/core/indexing.py ADDED
@@ -0,0 +1,94 @@
1
+ import os
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any, Optional
6
+ from akita.core.ast_utils import ASTParser
7
+
8
+ class CodeIndexer:
9
+ """
10
+ A lightweight, zero-dependency semantic-keyword indexer.
11
+ Uses basic TF-IDF principles and AST-aware keyword weighting.
12
+ Works perfectly even in restricted environments like Python 3.14.
13
+ """
14
+ def __init__(self, project_path: str):
15
+ self.project_path = Path(project_path)
16
+ self.index_file = self.project_path / ".akita" / "index.json"
17
+ self.ast_parser = ASTParser()
18
+ self.data: List[Dict[str, Any]] = []
19
+ self.load_index()
20
+
21
+ def load_index(self):
22
+ if self.index_file.exists():
23
+ try:
24
+ with open(self.index_file, "r", encoding="utf-8") as f:
25
+ self.data = json.load(f)
26
+ except Exception:
27
+ self.data = []
28
+
29
+ def save_index(self):
30
+ self.index_file.parent.mkdir(parents=True, exist_ok=True)
31
+ with open(self.index_file, "w", encoding="utf-8") as f:
32
+ json.dump(self.data, f, indent=2)
33
+
34
+ def index_project(self):
35
+ """Index all Python files in the project."""
36
+ self.data = []
37
+ for root, _, files in os.walk(self.project_path):
38
+ if ".akita" in root or ".git" in root or "__pycache__" in root:
39
+ continue
40
+
41
+ for file in files:
42
+ if file.endswith(".py"):
43
+ full_path = Path(root) / file
44
+ rel_path = full_path.relative_to(self.project_path)
45
+ self._index_file(full_path, str(rel_path))
46
+ self.save_index()
47
+
48
+ def _index_file(self, file_path: Path, rel_path: str):
49
+ try:
50
+ definitions = self.ast_parser.get_definitions(str(file_path))
51
+ for d in definitions:
52
+ source = self.ast_parser.get_source_segment(
53
+ str(file_path), d["start_line"], d["end_line"]
54
+ )
55
+
56
+ # Create a searchable representation (keyword rich)
57
+ # We normalize case and extract meaningful words
58
+ search_blob = f"{d['name']} {d['type']} {d['docstring'] or ''} {source}"
59
+ keywords = set(re.findall(r'\w+', search_blob.lower()))
60
+
61
+ self.data.append({
62
+ "path": rel_path,
63
+ "name": d["name"],
64
+ "type": d["type"],
65
+ "start_line": d["start_line"],
66
+ "end_line": d["end_line"],
67
+ "keywords": list(keywords),
68
+ "content": source[:500] # Store snippet preview
69
+ })
70
+ except Exception:
71
+ pass
72
+
73
+ def search(self, query: str, n_results: int = 5) -> List[Dict[str, Any]]:
74
+ """Search using Jaccard Similarity on keywords (Lite Contextual Search)."""
75
+ query_keywords = set(re.findall(r'\w+', query.lower()))
76
+ if not query_keywords:
77
+ return []
78
+
79
+ scores = []
80
+ for item in self.data:
81
+ item_keywords = set(item["keywords"])
82
+ intersection = query_keywords.intersection(item_keywords)
83
+ # Simple intersection count as score, weighted by name match
84
+ score = len(intersection)
85
+ if any(qk in item["name"].lower() for qk in query_keywords):
86
+ score += 5 # Boost explicit name matches
87
+
88
+ if score > 0:
89
+ scores.append((score, item))
90
+
91
+ # Sort by score descending
92
+ scores.sort(key=lambda x: x[0], reverse=True)
93
+
94
+ return [s[1] for s in scores[:n_results]]
akita/core/plugins.py ADDED
@@ -0,0 +1,81 @@
1
+ import abc
2
+ import importlib
3
+ import importlib.metadata
4
+ import inspect
5
+ import pkgutil
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Type, Optional
8
+
9
+ class AkitaPlugin(abc.ABC):
10
+ """Base class for all AkitaLLM plugins."""
11
+
12
+ @property
13
+ @abc.abstractmethod
14
+ def name(self) -> str:
15
+ """Unique name of the plugin."""
16
+ pass
17
+
18
+ @property
19
+ @abc.abstractmethod
20
+ def description(self) -> str:
21
+ """Brief description of what the plugin does."""
22
+ pass
23
+
24
+ @abc.abstractmethod
25
+ def get_tools(self) -> List[Dict[str, Any]]:
26
+ """Return a list of tools (functions) provided by this plugin."""
27
+ pass
28
+
29
+ class PluginManager:
30
+ def __init__(self, internal_plugins_path: Optional[str] = None):
31
+ self.plugins: Dict[str, AkitaPlugin] = {}
32
+ self.internal_path = internal_plugins_path or str(Path(__file__).parent.parent / "plugins")
33
+
34
+ def discover_all(self):
35
+ """Discover both internal and external plugins."""
36
+ self._discover_internal()
37
+ self._discover_external()
38
+
39
+ def _discover_internal(self):
40
+ """Load plugins from the akita/plugins directory."""
41
+ path = Path(self.internal_path)
42
+ if not path.exists():
43
+ return
44
+
45
+ for loader, module_name, is_pkg in pkgutil.iter_modules([str(path)]):
46
+ full_module_name = f"akita.plugins.{module_name}"
47
+ try:
48
+ module = importlib.import_module(full_module_name)
49
+ self._load_from_module(module)
50
+ except Exception as e:
51
+ print(f"Error loading internal plugin {module_name}: {e}")
52
+
53
+ def _discover_external(self):
54
+ """Load plugins registered via entry_points (akitallm.plugins)."""
55
+ try:
56
+ # Python 3.10+
57
+ eps = importlib.metadata.entry_points(group='akitallm.plugins')
58
+ for entry_point in eps:
59
+ try:
60
+ plugin_class = entry_point.load()
61
+ if inspect.isclass(plugin_class) and issubclass(plugin_class, AkitaPlugin):
62
+ instance = plugin_class()
63
+ self.plugins[instance.name] = instance
64
+ except Exception as e:
65
+ print(f"Error loading external plugin {entry_point.name}: {e}")
66
+ except Exception:
67
+ pass
68
+
69
+ def _load_from_module(self, module):
70
+ """Extract AkitaPlugin classes from a module and instantiate them."""
71
+ for name, obj in inspect.getmembers(module):
72
+ if inspect.isclass(obj) and issubclass(obj, AkitaPlugin) and obj is not AkitaPlugin:
73
+ instance = obj()
74
+ self.plugins[instance.name] = instance
75
+
76
+ def get_all_tools(self) -> List[Dict[str, Any]]:
77
+ """Collect all tools from all loaded plugins."""
78
+ all_tools = []
79
+ for plugin in self.plugins.values():
80
+ all_tools.extend(plugin.get_tools())
81
+ return all_tools