gemcode 0.2.2__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.
Files changed (58) hide show
  1. gemcode/__init__.py +3 -0
  2. gemcode/__main__.py +3 -0
  3. gemcode/agent.py +146 -0
  4. gemcode/audit.py +16 -0
  5. gemcode/callbacks.py +473 -0
  6. gemcode/capability_routing.py +137 -0
  7. gemcode/cli.py +658 -0
  8. gemcode/compaction.py +35 -0
  9. gemcode/computer_use/__init__.py +0 -0
  10. gemcode/computer_use/browser_computer.py +275 -0
  11. gemcode/config.py +247 -0
  12. gemcode/interactions.py +15 -0
  13. gemcode/invoke.py +151 -0
  14. gemcode/kairos_daemon.py +221 -0
  15. gemcode/limits.py +83 -0
  16. gemcode/live_audio_engine.py +124 -0
  17. gemcode/mcp_loader.py +57 -0
  18. gemcode/memory/__init__.py +0 -0
  19. gemcode/memory/embedding_memory_service.py +292 -0
  20. gemcode/memory/file_memory_service.py +176 -0
  21. gemcode/modality_tools.py +216 -0
  22. gemcode/model_routing.py +179 -0
  23. gemcode/paths.py +29 -0
  24. gemcode/permissions.py +5 -0
  25. gemcode/plugins/__init__.py +0 -0
  26. gemcode/plugins/terminal_hooks_plugin.py +168 -0
  27. gemcode/plugins/tool_recovery_plugin.py +135 -0
  28. gemcode/prompt_suggestions.py +80 -0
  29. gemcode/query/__init__.py +36 -0
  30. gemcode/query/config.py +35 -0
  31. gemcode/query/deps.py +20 -0
  32. gemcode/query/engine.py +55 -0
  33. gemcode/query/stop_hooks.py +63 -0
  34. gemcode/query/token_budget.py +109 -0
  35. gemcode/query/transitions.py +41 -0
  36. gemcode/session_runtime.py +81 -0
  37. gemcode/thinking.py +136 -0
  38. gemcode/tool_prompt_manifest.py +118 -0
  39. gemcode/tool_registry.py +50 -0
  40. gemcode/tools/__init__.py +25 -0
  41. gemcode/tools/edit.py +53 -0
  42. gemcode/tools/filesystem.py +73 -0
  43. gemcode/tools/search.py +85 -0
  44. gemcode/tools/shell.py +73 -0
  45. gemcode/tools_inspector.py +132 -0
  46. gemcode/trust.py +54 -0
  47. gemcode/tui/app.py +697 -0
  48. gemcode/tui/scrollback.py +312 -0
  49. gemcode/vertex.py +22 -0
  50. gemcode/web/__init__.py +2 -0
  51. gemcode/web/claude_sse_adapter.py +282 -0
  52. gemcode/web/terminal_repl.py +147 -0
  53. gemcode-0.2.2.dist-info/METADATA +440 -0
  54. gemcode-0.2.2.dist-info/RECORD +58 -0
  55. gemcode-0.2.2.dist-info/WHEEL +5 -0
  56. gemcode-0.2.2.dist-info/entry_points.txt +2 -0
  57. gemcode-0.2.2.dist-info/licenses/LICENSE +151 -0
  58. gemcode-0.2.2.dist-info/top_level.txt +1 -0
gemcode/tools/edit.py ADDED
@@ -0,0 +1,53 @@
1
+ """Write and search_replace tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from gemcode.config import GemCodeConfig
8
+ from gemcode.paths import PathEscapeError, resolve_under_root
9
+
10
+
11
+ def make_edit_tools(cfg: GemCodeConfig):
12
+ root = cfg.project_root
13
+
14
+ def write_file(path: str, content: str) -> dict:
15
+ """Create or overwrite a file relative to the project root."""
16
+ try:
17
+ p = resolve_under_root(root, path)
18
+ except PathEscapeError as e:
19
+ return {"error": str(e)}
20
+ p.parent.mkdir(parents=True, exist_ok=True)
21
+ p.write_text(content, encoding="utf-8")
22
+ return {"path": path, "bytes_written": len(content.encode("utf-8"))}
23
+
24
+ def search_replace(
25
+ path: str,
26
+ old_string: str,
27
+ new_string: str,
28
+ replace_all: bool = False,
29
+ ) -> dict:
30
+ """
31
+ Replace old_string with new_string in a text file. Fails if old_string
32
+ is missing or duplicate (unless replace_all=True).
33
+ """
34
+ try:
35
+ p = resolve_under_root(root, path)
36
+ except PathEscapeError as e:
37
+ return {"error": str(e)}
38
+ if not p.is_file():
39
+ return {"error": f"Not a file: {path}"}
40
+ text = p.read_text(encoding="utf-8", errors="strict")
41
+ count = text.count(old_string)
42
+ if count == 0:
43
+ return {"error": "old_string not found"}
44
+ if count > 1 and not replace_all:
45
+ return {"error": f"old_string appears {count} times; set replace_all=true or narrow snippet"}
46
+ if replace_all:
47
+ new_text = text.replace(old_string, new_string)
48
+ else:
49
+ new_text = text.replace(old_string, new_string, 1)
50
+ p.write_text(new_text, encoding="utf-8")
51
+ return {"path": path, "replacements": count if replace_all else 1}
52
+
53
+ return write_file, search_replace
@@ -0,0 +1,73 @@
1
+ """Read and list files under project root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from gemcode.config import GemCodeConfig
8
+ from gemcode.paths import PathEscapeError, resolve_under_root
9
+ from gemcode.trust import is_trusted_root
10
+
11
+
12
+ def make_filesystem_tools(cfg: GemCodeConfig):
13
+ root = cfg.project_root
14
+ trusted = is_trusted_root(root)
15
+
16
+ def read_file(path: str, max_bytes: int = 200_000) -> dict:
17
+ """Read a text file relative to the project root. Large files are truncated."""
18
+ if not trusted:
19
+ return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
20
+ try:
21
+ p = resolve_under_root(root, path)
22
+ except PathEscapeError as e:
23
+ return {"error": str(e)}
24
+ if not p.is_file():
25
+ return {"error": f"Not a file: {path}"}
26
+ data = p.read_bytes()
27
+ truncated = len(data) > max_bytes
28
+ text = data[:max_bytes].decode("utf-8", errors="replace")
29
+ return {
30
+ "path": path,
31
+ "content": text,
32
+ "truncated": truncated,
33
+ "total_bytes": len(data),
34
+ }
35
+
36
+ def list_directory(path: str = ".") -> dict:
37
+ """List files and directories under path (relative to project root)."""
38
+ if not trusted:
39
+ return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
40
+ try:
41
+ p = resolve_under_root(root, path)
42
+ except PathEscapeError as e:
43
+ return {"error": str(e)}
44
+ if not p.is_dir():
45
+ return {"error": f"Not a directory: {path}"}
46
+ entries: list[dict] = []
47
+ for child in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
48
+ entries.append(
49
+ {
50
+ "name": child.name,
51
+ "type": "dir" if child.is_dir() else "file",
52
+ }
53
+ )
54
+ return {"path": path, "entries": entries[:500]}
55
+
56
+ def glob_files(pattern: str) -> dict:
57
+ """Glob file paths relative to project root (e.g. 'src/**/*.py')."""
58
+ if not trusted:
59
+ return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
60
+ if ".." in pattern or pattern.startswith("/"):
61
+ return {"error": "Invalid pattern"}
62
+ matches: list[str] = []
63
+ for m in root.glob(pattern):
64
+ try:
65
+ rel = m.resolve().relative_to(root)
66
+ except ValueError:
67
+ continue
68
+ matches.append(str(rel))
69
+ if len(matches) >= 200:
70
+ break
71
+ return {"pattern": pattern, "matches": matches}
72
+
73
+ return read_file, list_directory, glob_files
@@ -0,0 +1,85 @@
1
+ """Grep content with regex (MVP: Python scan, optional rg)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from gemcode.config import GemCodeConfig
10
+
11
+
12
+ def make_grep_tool(cfg: GemCodeConfig):
13
+ root = cfg.project_root
14
+
15
+ def grep_content(
16
+ pattern: str,
17
+ path_glob: str = "**/*",
18
+ max_matches: int = 80,
19
+ ) -> dict:
20
+ """
21
+ Search file contents with a regex pattern. Scans files under path_glob
22
+ (glob relative to project root). Binary files skipped.
23
+ """
24
+ if max_matches < 1:
25
+ max_matches = 1
26
+ if max_matches > 500:
27
+ max_matches = 500
28
+ try:
29
+ re.compile(pattern)
30
+ except re.error as e:
31
+ return {"error": f"Invalid regex: {e}"}
32
+
33
+ # Prefer ripgrep if available (faster)
34
+ rg = Path("/usr/bin/rg") if Path("/usr/bin/rg").is_file() else None
35
+ if rg is None:
36
+ rg = Path("/opt/homebrew/bin/rg") if Path("/opt/homebrew/bin/rg").is_file() else None
37
+ if rg and rg.is_file():
38
+ try:
39
+ proc = subprocess.run(
40
+ [
41
+ str(rg),
42
+ "-n",
43
+ "--glob",
44
+ path_glob,
45
+ "--glob",
46
+ "!.git/*",
47
+ pattern,
48
+ ".",
49
+ ],
50
+ cwd=root,
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=60,
54
+ check=False,
55
+ )
56
+ lines = proc.stdout.splitlines()[:max_matches]
57
+ return {"pattern": pattern, "matches": lines, "backend": "rg"}
58
+ except (subprocess.TimeoutExpired, OSError):
59
+ pass
60
+
61
+ rx = re.compile(pattern)
62
+ matches: list[str] = []
63
+ for fp in root.glob(path_glob):
64
+ if not fp.is_file():
65
+ continue
66
+ if fp.stat().st_size > 2_000_000:
67
+ continue
68
+ try:
69
+ text = fp.read_text(encoding="utf-8", errors="ignore")
70
+ except OSError:
71
+ continue
72
+ for i, line in enumerate(text.splitlines(), 1):
73
+ if rx.search(line):
74
+ rel = fp.resolve().relative_to(root)
75
+ matches.append(f"{rel}:{i}:{line[:500]}")
76
+ if len(matches) >= max_matches:
77
+ return {
78
+ "pattern": pattern,
79
+ "matches": matches,
80
+ "truncated": True,
81
+ "backend": "python",
82
+ }
83
+ return {"pattern": pattern, "matches": matches, "backend": "python"}
84
+
85
+ return grep_content
gemcode/tools/shell.py ADDED
@@ -0,0 +1,73 @@
1
+ """Allowlisted subprocess execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from gemcode.config import GemCodeConfig
11
+ from gemcode.trust import is_trusted_root
12
+
13
+
14
+ def make_run_command(cfg: GemCodeConfig):
15
+ root = cfg.project_root
16
+ trusted = is_trusted_root(root)
17
+
18
+ def run_command(
19
+ command: str,
20
+ args: list[str] | None = None,
21
+ timeout_seconds: int = 120,
22
+ ) -> dict:
23
+ """
24
+ Run an allowlisted executable with arguments under the project root cwd.
25
+
26
+ The executable must be a basename (no shell metacharacters) and appear in
27
+ GEMCODE_ALLOW_COMMANDS / default allowlist.
28
+ """
29
+ if not trusted:
30
+ return {"error": "Project folder is not trusted. Re-run GemCode and approve folder trust."}
31
+ args = args or []
32
+ if timeout_seconds < 1:
33
+ timeout_seconds = 1
34
+ if timeout_seconds > 600:
35
+ timeout_seconds = 600
36
+ if any(c in command for c in ";|&$`"):
37
+ return {"error": "Command must be a single executable name, not a shell snippet"}
38
+ exe = Path(command).name
39
+ if exe != command:
40
+ return {"error": "Use basename only for command (e.g. pytest, not /usr/bin/pytest)"}
41
+
42
+ allowed = cfg.allow_commands
43
+ if exe not in allowed:
44
+ return {
45
+ "error": (
46
+ f"Command {exe!r} not in allowlist. Add it to GEMCODE_ALLOW_COMMANDS "
47
+ f"(comma-separated)."
48
+ )
49
+ }
50
+
51
+ resolved = shutil.which(exe)
52
+ if not resolved:
53
+ return {"error": f"Executable not found on PATH: {exe}"}
54
+ try:
55
+ proc = subprocess.run(
56
+ [resolved, *args],
57
+ cwd=root,
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=timeout_seconds,
61
+ env={**os.environ},
62
+ check=False,
63
+ )
64
+ return {
65
+ "command": [exe, *args],
66
+ "exit_code": proc.returncode,
67
+ "stdout": proc.stdout[:50_000],
68
+ "stderr": proc.stderr[:50_000],
69
+ }
70
+ except subprocess.TimeoutExpired:
71
+ return {"error": f"Timeout after {timeout_seconds}s"}
72
+
73
+ return run_command
@@ -0,0 +1,132 @@
1
+ """
2
+ GemCode tool inventory + declaration smoke testing.
3
+
4
+ Claude Code's tool-system prompt emphasizes:
5
+ - inventory of available tools (feature-gated vs always-on)
6
+ - schema/declaration compilation smoke tests per tool
7
+ - permission/category metadata
8
+
9
+ In GemCode (ADK-based), tool declarations are produced by ADK via:
10
+ - wrapping callables in `google.adk.tools.function_tool.FunctionTool`
11
+ - calling `_get_declaration()` on BaseTool implementations
12
+
13
+ This module provides a deterministic way to:
14
+ - enumerate tools for a given `GemCodeConfig`
15
+ - validate each tool can build its declaration (when supported)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Any, Iterable
22
+
23
+ from google.adk.tools.base_tool import BaseTool
24
+ from google.adk.tools.function_tool import FunctionTool
25
+
26
+ from gemcode.config import GemCodeConfig
27
+ from gemcode.modality_tools import build_extra_tools as build_modality_tools
28
+ from gemcode.tools import build_function_tools
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ToolInspection:
33
+ name: str
34
+ category: str
35
+ declaration_present: bool
36
+ declaration_error: str | None = None
37
+ tool_type: str = "callable" # "callable" | "builtin"
38
+
39
+
40
+ def _tool_name(tool_union: Any) -> str:
41
+ if isinstance(tool_union, BaseTool):
42
+ return getattr(tool_union, "name", "") or ""
43
+ if callable(tool_union):
44
+ return getattr(tool_union, "__name__", "") or ""
45
+ return ""
46
+
47
+
48
+ def _classify_tool(name: str) -> str:
49
+ # Keep this consistent with gemcode/src/gemcode/tool_registry.py
50
+ from gemcode.tool_registry import READ_ONLY_TOOLS, MUTATING_TOOLS, SHELL_TOOLS
51
+
52
+ if name in READ_ONLY_TOOLS:
53
+ return "read_only"
54
+ if name in MUTATING_TOOLS:
55
+ return "mutating"
56
+ if name in SHELL_TOOLS:
57
+ return "shell"
58
+ return "other"
59
+
60
+
61
+ def _iter_tool_unions(cfg: GemCodeConfig, *, extra_tools: Iterable[Any] | None = None):
62
+ # Core function tools (always available for the agent).
63
+ tools: list[Any] = list(build_function_tools(cfg))
64
+
65
+ # Deep research built-in tools (Search/URL/Maps).
66
+ tools.extend(build_modality_tools(cfg))
67
+
68
+ # Optional MCP toolsets are provided as `extra_tools` by the caller (CLI).
69
+ if extra_tools:
70
+ tools.extend(list(extra_tools))
71
+
72
+ # Note: ComputerUseToolset requires launching Playwright via BrowserComputer.
73
+ # Tool declaration smoke tests can be slow and brittle, so we intentionally
74
+ # omit it from inventory unless the caller constructs it already.
75
+ #
76
+ # If you want it included, pass it via `extra_tools` explicitly.
77
+ return tools
78
+
79
+
80
+ def inspect_tools(
81
+ cfg: GemCodeConfig,
82
+ *,
83
+ extra_tools: Iterable[Any] | None = None,
84
+ ) -> list[ToolInspection]:
85
+ """
86
+ Enumerate tools for this config and attempt to compile each tool's
87
+ declaration (schema) without executing tool logic.
88
+ """
89
+ out: list[ToolInspection] = []
90
+
91
+ for tool_union in _iter_tool_unions(cfg, extra_tools=extra_tools):
92
+ name = _tool_name(tool_union) or "<unknown>"
93
+ category = _classify_tool(name)
94
+ tool_type = "builtin" if isinstance(tool_union, BaseTool) else "callable"
95
+
96
+ declaration_present = False
97
+ declaration_error: str | None = None
98
+
99
+ try:
100
+ if isinstance(tool_union, BaseTool):
101
+ decl = tool_union._get_declaration()
102
+ else:
103
+ # For pure callables, ADK uses FunctionTool to build FunctionDeclaration.
104
+ decl_tool = FunctionTool(func=tool_union) # type: ignore[arg-type]
105
+ decl = decl_tool._get_declaration()
106
+
107
+ declaration_present = decl is not None
108
+ except Exception as e:
109
+ declaration_present = False
110
+ declaration_error = f"{type(e).__name__}: {e}"
111
+
112
+ out.append(
113
+ ToolInspection(
114
+ name=name,
115
+ category=category,
116
+ declaration_present=declaration_present,
117
+ declaration_error=declaration_error,
118
+ tool_type=tool_type,
119
+ )
120
+ )
121
+
122
+ # Stable output ordering.
123
+ out.sort(key=lambda x: (x.category, x.name.lower()))
124
+ return out
125
+
126
+
127
+ def smoke_tools(
128
+ inspections: list[ToolInspection],
129
+ ) -> list[ToolInspection]:
130
+ """Return only the tools that failed declaration compilation."""
131
+ return [i for i in inspections if i.declaration_error is not None]
132
+
gemcode/trust.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ def _trust_file_path() -> Path:
9
+ base = Path(os.environ.get("GEMCODE_HOME") or (Path.home() / ".gemcode"))
10
+ return base / "trust.json"
11
+
12
+
13
+ def load_trusted_roots() -> set[str]:
14
+ """
15
+ Returns a set of resolved absolute paths (as strings) that are trusted.
16
+ """
17
+ p = _trust_file_path()
18
+ try:
19
+ data = json.loads(p.read_text("utf-8"))
20
+ except FileNotFoundError:
21
+ return set()
22
+ except Exception:
23
+ return set()
24
+ roots = data.get("trusted_roots") if isinstance(data, dict) else None
25
+ if not isinstance(roots, list):
26
+ return set()
27
+ out: set[str] = set()
28
+ for r in roots:
29
+ if isinstance(r, str) and r:
30
+ out.add(str(Path(r).resolve()))
31
+ return out
32
+
33
+
34
+ def save_trusted_roots(roots: set[str]) -> None:
35
+ p = _trust_file_path()
36
+ p.parent.mkdir(parents=True, exist_ok=True)
37
+ payload = {"trusted_roots": sorted(set(str(Path(r).resolve()) for r in roots))}
38
+ p.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
39
+
40
+
41
+ def is_trusted_root(root: Path) -> bool:
42
+ r = str(root.resolve())
43
+ return r in load_trusted_roots()
44
+
45
+
46
+ def trust_root(root: Path, *, trusted: bool) -> None:
47
+ roots = load_trusted_roots()
48
+ r = str(root.resolve())
49
+ if trusted:
50
+ roots.add(r)
51
+ else:
52
+ roots.discard(r)
53
+ save_trusted_roots(roots)
54
+