gemcode 0.3.28__tar.gz → 0.3.30__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 (116) hide show
  1. {gemcode-0.3.28/src/gemcode.egg-info → gemcode-0.3.30}/PKG-INFO +1 -1
  2. {gemcode-0.3.28 → gemcode-0.3.30}/pyproject.toml +1 -1
  3. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/agent.py +116 -4
  4. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/callbacks.py +68 -0
  5. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/config.py +31 -0
  6. gemcode-0.3.30/src/gemcode/hooks.py +194 -0
  7. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/modality_tools.py +15 -1
  8. gemcode-0.3.30/src/gemcode/pricing.py +76 -0
  9. gemcode-0.3.30/src/gemcode/refine.py +204 -0
  10. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/repl_commands.py +29 -1
  11. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/repl_slash.py +85 -2
  12. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/session_runtime.py +24 -1
  13. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tool_prompt_manifest.py +3 -0
  14. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/input_handler.py +20 -1
  15. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/scrollback.py +44 -1
  16. {gemcode-0.3.28 → gemcode-0.3.30/src/gemcode.egg-info}/PKG-INFO +1 -1
  17. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/SOURCES.txt +3 -0
  18. {gemcode-0.3.28 → gemcode-0.3.30}/LICENSE +0 -0
  19. {gemcode-0.3.28 → gemcode-0.3.30}/MANIFEST.in +0 -0
  20. {gemcode-0.3.28 → gemcode-0.3.30}/README.md +0 -0
  21. {gemcode-0.3.28 → gemcode-0.3.30}/setup.cfg +0 -0
  22. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/__init__.py +0 -0
  23. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/__main__.py +0 -0
  24. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/audit.py +0 -0
  25. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/autocompact.py +0 -0
  26. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/capability_routing.py +0 -0
  27. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/cli.py +0 -0
  28. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/compaction.py +0 -0
  29. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/computer_use/__init__.py +0 -0
  30. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/computer_use/browser_computer.py +0 -0
  31. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/context_budget.py +0 -0
  32. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/context_warning.py +0 -0
  33. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/credentials.py +0 -0
  34. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/hitl_session.py +0 -0
  35. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/interactions.py +0 -0
  36. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/invoke.py +0 -0
  37. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/kairos_daemon.py +0 -0
  38. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/limits.py +0 -0
  39. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/live_audio_engine.py +0 -0
  40. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/logging_config.py +0 -0
  41. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/mcp_loader.py +0 -0
  42. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/__init__.py +0 -0
  43. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/embedding_memory_service.py +0 -0
  44. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/file_memory_service.py +0 -0
  45. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/model_errors.py +0 -0
  46. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/model_routing.py +0 -0
  47. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/paths.py +0 -0
  48. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/permissions.py +0 -0
  49. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/__init__.py +0 -0
  50. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  51. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  52. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/prompt_suggestions.py +0 -0
  53. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/__init__.py +0 -0
  54. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/config.py +0 -0
  55. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/deps.py +0 -0
  56. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/engine.py +0 -0
  57. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/stop_hooks.py +0 -0
  58. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/token_budget.py +0 -0
  59. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/transitions.py +0 -0
  60. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/slash_commands.py +0 -0
  61. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/thinking.py +0 -0
  62. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tool_registry.py +0 -0
  63. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/__init__.py +0 -0
  64. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/bash.py +0 -0
  65. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/browser.py +0 -0
  66. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/edit.py +0 -0
  67. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/filesystem.py +0 -0
  68. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/search.py +0 -0
  69. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/shell.py +0 -0
  70. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/shell_gate.py +0 -0
  71. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/subtask.py +0 -0
  72. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/think.py +0 -0
  73. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/todo.py +0 -0
  74. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/web.py +0 -0
  75. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools_inspector.py +0 -0
  76. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/trust.py +0 -0
  77. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/spinner.py +0 -0
  78. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/welcome_banner.py +0 -0
  79. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/welcome_rich.py +0 -0
  80. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/version.py +0 -0
  81. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/vertex.py +0 -0
  82. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/__init__.py +0 -0
  83. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/claude_sse_adapter.py +0 -0
  84. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/terminal_repl.py +0 -0
  85. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/workspace_hints.py +0 -0
  86. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/dependency_links.txt +0 -0
  87. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/entry_points.txt +0 -0
  88. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/requires.txt +0 -0
  89. {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/top_level.txt +0 -0
  90. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_agent_instruction.py +0 -0
  91. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_autocompact.py +0 -0
  92. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_capability_routing.py +0 -0
  93. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_claude_web_adapter_sse.py +0 -0
  94. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_cli_init.py +0 -0
  95. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_computer_use_permissions.py +0 -0
  96. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_context_budget.py +0 -0
  97. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_context_warning.py +0 -0
  98. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_credentials.py +0 -0
  99. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_interactive_permission_ask.py +0 -0
  100. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_kairos_scheduler.py +0 -0
  101. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_modality_tools.py +0 -0
  102. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_error_retry.py +0 -0
  103. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_errors.py +0 -0
  104. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_routing.py +0 -0
  105. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_paths.py +0 -0
  106. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_permissions.py +0 -0
  107. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_prompt_suggestions.py +0 -0
  108. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_repl_commands.py +0 -0
  109. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_repl_slash.py +0 -0
  110. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_slash_commands.py +0 -0
  111. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_thinking_config.py +0 -0
  112. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_token_budget.py +0 -0
  113. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tool_context_circulation.py +0 -0
  114. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tools.py +0 -0
  115. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tools_inspector.py +0 -0
  116. {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.28
3
+ Version: 0.3.30
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.28"
7
+ version = "0.3.30"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -66,6 +66,8 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
66
66
 
67
67
  # ── Active capabilities ──────────────────────────────────────────────────
68
68
  caps: list[str] = []
69
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
70
+ caps.append("web_search ON (tool: google_search — standalone search without full deep_research)")
69
71
  if getattr(cfg, "enable_deep_research", False):
70
72
  dr_extras = " + google_maps_grounding" if getattr(cfg, "enable_maps_grounding", False) else ""
71
73
  caps.append(f"deep_research ON (tools: google_search, url_context{dr_extras})")
@@ -77,8 +79,12 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
77
79
  caps.append(f"memory ON ({mem_kind}, stored at {mem_path}; ADK preload_memory injects relevant memories before each turn)")
78
80
  if getattr(cfg, "enable_computer_use", False):
79
81
  caps.append("computer_use ON (tools: navigate, click_at, type_text_at, browser_screenshot, browser_find_element, etc.)")
82
+ if getattr(cfg, "enable_code_executor", False):
83
+ caps.append("code_executor ON — you can write Python code blocks and they will be executed safely via Gemini's built-in sandboxed executor; results appear as code_execution_result events. Use this for math, data processing, quick tests, and anything that would otherwise require a shell command.")
84
+ if getattr(cfg, "enable_artifacts", True):
85
+ caps.append("artifacts ON — use save_artifact(filename, bytes, mime_type) / load_artifact(filename) to store large/binary outputs (screenshots, PDFs, generated files) outside session history. Artifacts are keyed by filename; prefix 'user:' for cross-session persistence.")
80
86
  if not caps:
81
- caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on to enable)")
87
+ caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on, /code on to enable)")
82
88
  caps_text = "\n".join(f" - {c}" for c in caps)
83
89
 
84
90
  # ── Limits ───────────────────────────────────────────────────────────────
@@ -147,6 +153,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
147
153
  """
148
154
 
149
155
 
156
+ def _build_plan_mode_section() -> str:
157
+ """Injected when plan_mode=True — instructs agent to write explicit plans first."""
158
+ return """
159
+ ## PLAN MODE IS ACTIVE
160
+
161
+ You are currently in **Plan Mode**. Before executing ANY tools that modify files or run shell commands, you MUST:
162
+
163
+ 1. **Write a numbered plan** in your response text — list every step you intend to take.
164
+ Example:
165
+ ```
166
+ Plan:
167
+ 1. Read src/auth/login.ts to understand the current flow
168
+ 2. Read src/types/user.ts for the User interface
169
+ 3. Add `lastLogin: Date` field to User interface
170
+ 4. Update login handler to set lastLogin on successful auth
171
+ 5. Run `npm run build` to verify no TypeScript errors
172
+ ```
173
+
174
+ 2. **Pause after the plan** — do not immediately execute tools. Present the plan and wait for the user to confirm ("go", "proceed", "looks good") before starting.
175
+
176
+ 3. **Stick to the plan** — if you discover the plan needs changing mid-execution, note the update before proceeding.
177
+
178
+ 4. **Report completion** against the plan — when done, confirm each step as completed.
179
+
180
+ **Why plan mode?**
181
+ - Prevents unintended side effects from premature tool execution
182
+ - Gives you and the user visibility into the full scope before any changes
183
+ - Makes complex multi-file tasks reviewable and reversible
184
+ - Catches scope creep early
185
+
186
+ **To turn off plan mode**, type `/plan off` at the prompt.
187
+ """
188
+
189
+
150
190
  def _build_computer_use_section(cfg: GemCodeConfig) -> str:
151
191
  """Rich computer use guidance, only injected when enable_computer_use=True."""
152
192
  w = getattr(cfg, "_cfg", None)
@@ -426,6 +466,41 @@ Use `run_subtask` when the work is better done in an isolated context:
426
466
 
427
467
  The sub-agent inherits your permission settings and returns its final text as `result`. Treat it as a trusted colleague returning a written summary.
428
468
 
469
+ ## ADK Special Tools (always available when ADK supports them)
470
+
471
+ ### `get_user_choice`
472
+ Present the user with a structured multi-option prompt rather than open-ended questions.
473
+ Use when you need the user to pick from 2–6 specific options (e.g. "Which framework would you like?", "Choose migration strategy: A, B, or C").
474
+ This provides a better UX than asking them to type a free-form answer.
475
+
476
+ ### `load_artifacts`
477
+ Load binary/large artifacts that were saved in a previous turn or by a sub-agent.
478
+ Artifacts are keyed by filename (e.g. "report.pdf", "screenshot.png", "output.json").
479
+ Use `user:filename` prefix for user-scoped artifacts that persist across sessions.
480
+ After loading, the artifact bytes are available for further processing (display, analysis, transformation).
481
+
482
+ ### `exit_loop`
483
+ Signal the surrounding LoopAgent to stop iterating and return the final result.
484
+ Only meaningful when this agent is running inside an ADK LoopAgent pipeline.
485
+ Call this when the task is complete and no further iterations are needed.
486
+
487
+ ## Artifacts — storing large outputs
488
+ When `artifacts ON` (see Runtime facts above):
489
+ - **Save** large generated content as artifacts instead of printing them inline:
490
+ - Screenshots from computer_use: save as "screenshot.png" artifact
491
+ - Generated reports/PDFs: save as "report.pdf" artifact
492
+ - Large JSON data: save as "data.json" artifact
493
+ - **Reference** artifacts in instructions via `{artifact.filename?}` template syntax
494
+ - Artifacts are keyed by filename; `user:` prefix = cross-session persistence
495
+
496
+ ## Code Executor (sandboxed Python)
497
+ When `code_executor ON` (see Runtime facts above):
498
+ - You can write Python code blocks in your response and the Gemini API executes them safely
499
+ - The result appears as a `code_execution_result` event with stdout and the outcome
500
+ - Best for: math calculations, data transformation, unit testing logic, quick experiments
501
+ - The sandbox does NOT have internet access or filesystem access — use for pure computation
502
+ - For file I/O or shell commands, use the standard tools (`bash`, `write_file`, etc.)
503
+
429
504
  ## Evaluator-optimizer loop
430
505
  For tasks where quality matters:
431
506
  1. Complete the task (execute tools, write code, run commands)
@@ -461,6 +536,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
461
536
  if getattr(cfg, "enable_memory", False):
462
537
  base = f"{base}\n\n{_build_memory_section(cfg)}"
463
538
 
539
+ if getattr(cfg, "plan_mode", False):
540
+ base = f"{base}\n\n{_build_plan_mode_section()}"
541
+
464
542
  tool_manifest = build_tool_manifest(cfg)
465
543
  if tool_manifest:
466
544
  base = f"{base}\n\n{tool_manifest}"
@@ -470,6 +548,17 @@ All file tools use paths **relative to the project root** (where GemCode was sta
470
548
  return base
471
549
 
472
550
 
551
+ def _build_code_executor(cfg: GemCodeConfig):
552
+ """Return an ADK BuiltInCodeExecutor when enable_code_executor=True, else None."""
553
+ if not getattr(cfg, "enable_code_executor", False):
554
+ return None
555
+ try:
556
+ from google.adk.code_executors import BuiltInCodeExecutor
557
+ return BuiltInCodeExecutor()
558
+ except Exception:
559
+ return None
560
+
561
+
473
562
  def build_root_agent(
474
563
  cfg: GemCodeConfig,
475
564
  extra_tools: list | None = None,
@@ -488,12 +577,19 @@ def build_root_agent(
488
577
  if _tools is not None:
489
578
  tools = list(_tools)
490
579
  else:
491
- tools = build_function_tools(cfg)
580
+ tools = build_function_tools(cfg)
492
581
  if getattr(cfg, "enable_memory", False):
493
582
  # ADK preload_memory injects retrieved memories into the next llm_request.
494
583
  from google.adk.tools import preload_memory
495
-
496
584
  tools = [preload_memory, *tools]
585
+
586
+ # ADK built-in interactive + artifact tools — always available when ADK supports them.
587
+ try:
588
+ from google.adk.tools import get_user_choice, load_artifacts, exit_loop
589
+ tools = [*tools, get_user_choice, load_artifacts, exit_loop]
590
+ except Exception:
591
+ pass
592
+
497
593
  if extra_tools:
498
594
  tools = [*tools, *extra_tools]
499
595
 
@@ -548,15 +644,31 @@ def build_root_agent(
548
644
  tool_config=tool_cfg,
549
645
  )
550
646
 
551
- return LlmAgent(
647
+ # global_instruction applies to the entire agent tree (including sub-agents
648
+ # spawned via run_subtask or multi-agent delegation). Keep it short — it's
649
+ # prepended to every agent's effective instruction.
650
+ global_instr = (
651
+ "You are GemCode, an expert software engineering agent powered by Google Gemini. "
652
+ "Act, don't advise. Complete tasks fully and autonomously. "
653
+ "Think before destructive actions. Use read-only tools before shell/write tools."
654
+ )
655
+
656
+ agent_kwargs: dict = dict(
552
657
  model=cfg.model,
553
658
  name="gemcode",
554
659
  instruction=build_instruction(cfg),
660
+ global_instruction=global_instr,
555
661
  tools=tools,
556
662
  generate_content_config=gen_cfg,
557
663
  **cb_kwargs,
558
664
  )
559
665
 
666
+ code_executor = _build_code_executor(cfg)
667
+ if code_executor is not None:
668
+ agent_kwargs["code_executor"] = code_executor
669
+
670
+ return LlmAgent(**agent_kwargs)
671
+
560
672
 
561
673
  def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None):
562
674
  """Backward-compatible: prefer `gemcode.session_runtime.create_runner`."""
@@ -141,6 +141,22 @@ def make_before_tool_callback(cfg: GemCodeConfig):
141
141
  record = {"tool": name, "args": _redact_args(name, args)}
142
142
  append_audit(cfg.project_root, record)
143
143
 
144
+ # ── Shell hooks: pre_tool_use ─────────────────────────────────────────
145
+ # If the project has a .gemcode/hooks/pre_tool_use.sh, run it now.
146
+ # Non-zero exit or {"decision":"deny"} stdout will block the tool call.
147
+ try:
148
+ from gemcode.hooks import run_pre_tool_use_hook
149
+ hook_result = run_pre_tool_use_hook(
150
+ cfg.project_root,
151
+ model=getattr(cfg, "model", "") or "",
152
+ tool_name=name,
153
+ args=args or {},
154
+ )
155
+ if hook_result is not None:
156
+ return hook_result
157
+ except Exception:
158
+ pass
159
+
144
160
  streak = 0
145
161
  if tool_context is not None:
146
162
  try:
@@ -316,6 +332,19 @@ def make_after_tool_callback(cfg: GemCodeConfig):
316
332
  st[_STATE_FAILURE_KEY] = 0
317
333
  else:
318
334
  st[_STATE_FAILURE_KEY] = 0
335
+ # ── Shell hooks: post_tool_use ────────────────────────────────────────
336
+ try:
337
+ from gemcode.hooks import run_post_tool_use_hook
338
+ run_post_tool_use_hook(
339
+ cfg.project_root,
340
+ model=getattr(cfg, "model", "") or "",
341
+ tool_name=name,
342
+ args=args or {},
343
+ result=tool_response if isinstance(tool_response, dict) else {},
344
+ )
345
+ except Exception:
346
+ pass
347
+
319
348
  if _maybe_tool_summary_enabled():
320
349
  summary: dict[str, Any] = {
321
350
  "phase": "tool_result",
@@ -399,6 +428,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
399
428
  "candidates_token_count",
400
429
  "cached_content_token_count",
401
430
  "total_token_count",
431
+ "thoughts_token_count",
402
432
  ):
403
433
  if hasattr(um, attr):
404
434
  v = getattr(um, attr)
@@ -407,6 +437,44 @@ def make_after_model_callback(cfg: GemCodeConfig):
407
437
  if d:
408
438
  append_audit(cfg.project_root, {"phase": "model_usage", **d})
409
439
 
440
+ # ── Expose live token stats to the TUI ───────────────────────────────────
441
+ # The TUI reads cfg._last_turn_stats after each turn to display token counts
442
+ # and estimated cost in the footer (like OpenClaude's spinner token display).
443
+ try:
444
+ in_tok = d.get("prompt_token_count", 0) or 0
445
+ out_tok = d.get("candidates_token_count", 0) or 0
446
+ think_tok = d.get("thoughts_token_count", 0) or 0
447
+ cache_tok = d.get("cached_content_token_count", 0) or 0
448
+ total_tok = d.get("total_token_count", 0) or 0
449
+
450
+ prev_session_tokens = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
451
+ session_total = prev_session_tokens + total_tok
452
+
453
+ from gemcode.pricing import estimate_cost
454
+ turn_cost = estimate_cost(
455
+ getattr(cfg, "model", "") or "",
456
+ input_tokens=in_tok,
457
+ output_tokens=out_tok,
458
+ )
459
+ # Accumulate session cost
460
+ prev_cost = getattr(cfg, "_session_cost_usd", 0.0) or 0.0
461
+ session_cost = prev_cost + (turn_cost or 0.0)
462
+ object.__setattr__(cfg, "_session_cost_usd", session_cost)
463
+
464
+ stats: dict[str, Any] = {
465
+ "in": in_tok,
466
+ "out": out_tok,
467
+ "think": think_tok,
468
+ "cache": cache_tok,
469
+ "total": total_tok,
470
+ "session_total": session_total,
471
+ "turn_cost": turn_cost,
472
+ "session_cost": session_cost,
473
+ }
474
+ object.__setattr__(cfg, "_last_turn_stats", stats)
475
+ except Exception:
476
+ pass
477
+
410
478
  pt = d.get("prompt_token_count")
411
479
  if isinstance(pt, int) and pt >= 0:
412
480
  try:
@@ -251,6 +251,37 @@ class GemCodeConfig:
251
251
  default_factory=lambda: _truthy_env("GEMCODE_SHOW_FULL_THINKING", default=False)
252
252
  )
253
253
 
254
+ # Enable ADK BuiltInCodeExecutor for safe sandboxed Python execution via
255
+ # Gemini's code execution API. When on, the agent can write and run Python
256
+ # snippets inline (math, data processing, quick tests) without requiring
257
+ # bash/shell permissions. Requires a Gemini model that supports code execution
258
+ # (gemini-2.5-flash, gemini-2.5-pro, gemini-3.x).
259
+ enable_code_executor: bool = field(
260
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_CODE_EXECUTOR", default=False)
261
+ )
262
+
263
+ # Enable ADK artifact service for storing large/binary outputs (screenshots,
264
+ # generated files, reports) outside of session history.
265
+ # When on, the agent can save_artifact / load_artifact keyed by filename.
266
+ enable_artifacts: bool = field(
267
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_ARTIFACTS", default=True)
268
+ )
269
+
270
+ # Plan mode: when ON, the agent explicitly writes out a numbered plan
271
+ # BEFORE executing any tools, then checks the plan before reporting done.
272
+ # Like OpenClaude's EnterPlanMode — great for complex, multi-file tasks.
273
+ # Toggle at runtime with /plan on|off.
274
+ plan_mode: bool = field(
275
+ default_factory=lambda: _truthy_env("GEMCODE_PLAN_MODE", default=False)
276
+ )
277
+
278
+ # Always-on web search (independent of deep_research / research mode).
279
+ # When True, google_search is available as a basic tool without enabling
280
+ # the full deep_research capability suite (url_context, maps, etc.).
281
+ enable_web_search: bool = field(
282
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_WEB_SEARCH", default=False)
283
+ )
284
+
254
285
  def __post_init__(self) -> None:
255
286
  self.project_root = self.project_root.resolve()
256
287
  # Default agentic depth when env omits GEMCODE_MAX_LLM_CALLS (was: None → SDK default).
@@ -0,0 +1,194 @@
1
+ """
2
+ GemCode shell hooks — scriptable lifecycle events inspired by OpenClaude.
3
+
4
+ Hooks are shell scripts stored under ``<project_root>/.gemcode/hooks/``.
5
+ They receive a JSON payload on stdin and can:
6
+ - pre_tool_use.sh: return non-zero exit to DENY the tool call, or
7
+ print JSON {"decision":"deny","reason":"..."} to stderr.
8
+ - post_tool_use.sh: informational; return value ignored.
9
+ - session_start.sh: runs when a new GemCode session starts.
10
+ - session_stop.sh: runs when the session ends.
11
+
12
+ HOOK ENVIRONMENT
13
+ All hooks receive these env vars:
14
+ GEMCODE_HOOK_TYPE — "pre_tool_use" | "post_tool_use" | "session_start" | "session_stop"
15
+ GEMCODE_PROJECT_ROOT — absolute path to the project root
16
+ GEMCODE_MODEL — active model id
17
+
18
+ HOOK STDIN (pre_tool_use, post_tool_use)
19
+ JSON object with:
20
+ { "tool": "<tool_name>",
21
+ "args": { ...tool_args... },
22
+ "type": "pre_tool_use" | "post_tool_use",
23
+ "result": { ...tool_result... } // post only
24
+ }
25
+
26
+ PRE_TOOL_USE DECISION
27
+ If exit code is non-zero → the tool is DENIED.
28
+ If exit code is 0 → the tool proceeds normally.
29
+ If stdout starts with '{' and contains "decision": "deny" → the tool is denied.
30
+
31
+ EXAMPLE HOOKS
32
+ # .gemcode/hooks/pre_tool_use.sh
33
+ #!/bin/bash
34
+ # Deny all delete_file calls
35
+ HOOK=$(cat)
36
+ TOOL=$(echo "$HOOK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool'])")
37
+ if [ "$TOOL" = "delete_file" ]; then
38
+ echo "delete_file blocked by hook" >&2
39
+ exit 1
40
+ fi
41
+
42
+ # .gemcode/hooks/session_start.sh
43
+ #!/bin/bash
44
+ echo "[gemcode hook] session started in $GEMCODE_PROJECT_ROOT" >> /tmp/gemcode-audit.log
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import json
50
+ import logging
51
+ import os
52
+ import subprocess
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+ _HOOKS_DIR = ".gemcode/hooks"
59
+ _TIMEOUT_PRE = 5.0 # seconds — pre_tool_use must respond quickly
60
+ _TIMEOUT_POST = 10.0 # seconds — post_tool_use has more budget
61
+
62
+
63
+ def _find_hook(project_root: Path, name: str) -> Path | None:
64
+ """Return the hook file path if it exists and is executable, else None."""
65
+ hooks_dir = project_root / _HOOKS_DIR
66
+ for ext in ("", ".sh", ".py", ".bash"):
67
+ candidate = hooks_dir / f"{name}{ext}"
68
+ if candidate.is_file() and os.access(candidate, os.X_OK):
69
+ return candidate
70
+ return None
71
+
72
+
73
+ def _run_hook(
74
+ hook_path: Path,
75
+ project_root: Path,
76
+ model: str,
77
+ hook_type: str,
78
+ stdin_data: dict | None = None,
79
+ timeout: float = 5.0,
80
+ ) -> tuple[int, str, str]:
81
+ """
82
+ Run a hook script.
83
+
84
+ Returns (exit_code, stdout, stderr).
85
+ Never raises — all exceptions are caught and logged.
86
+ """
87
+ try:
88
+ env = {
89
+ **os.environ,
90
+ "GEMCODE_HOOK_TYPE": hook_type,
91
+ "GEMCODE_PROJECT_ROOT": str(project_root),
92
+ "GEMCODE_MODEL": model or "",
93
+ }
94
+ stdin_bytes = (
95
+ json.dumps(stdin_data, ensure_ascii=False).encode() if stdin_data else b""
96
+ )
97
+ result = subprocess.run(
98
+ [str(hook_path)],
99
+ input=stdin_bytes,
100
+ capture_output=True,
101
+ timeout=timeout,
102
+ env=env,
103
+ cwd=str(project_root),
104
+ )
105
+ return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace")
106
+ except subprocess.TimeoutExpired:
107
+ log.warning("[hooks] %s timed out after %.1fs", hook_path.name, timeout)
108
+ return 1, "", f"hook timed out after {timeout}s"
109
+ except Exception as exc:
110
+ log.debug("[hooks] failed to run %s: %s", hook_path.name, exc)
111
+ return 1, "", str(exc)
112
+
113
+
114
+ def run_pre_tool_use_hook(
115
+ project_root: Path,
116
+ model: str,
117
+ tool_name: str,
118
+ args: dict[str, Any],
119
+ ) -> dict | None:
120
+ """
121
+ Run the pre_tool_use hook (if it exists).
122
+
123
+ Returns:
124
+ None → tool is allowed (default)
125
+ dict → {"error": "...", "error_kind": "hook_denied"} to deny the tool
126
+ """
127
+ hook = _find_hook(project_root, "pre_tool_use")
128
+ if hook is None:
129
+ return None
130
+
131
+ payload = {"tool": tool_name, "args": args, "type": "pre_tool_use"}
132
+ rc, stdout, stderr = _run_hook(
133
+ hook, project_root, model, "pre_tool_use", payload, timeout=_TIMEOUT_PRE
134
+ )
135
+
136
+ if rc != 0:
137
+ reason = stderr.strip() or stdout.strip() or f"hook exited {rc}"
138
+ log.info("[hooks] pre_tool_use denied %s: %s", tool_name, reason[:200])
139
+ return {
140
+ "error": f"Tool denied by pre_tool_use hook: {reason[:400]}",
141
+ "error_kind": "hook_denied",
142
+ }
143
+
144
+ # Check if stdout contains an explicit deny decision.
145
+ if stdout.strip().startswith("{"):
146
+ try:
147
+ decision = json.loads(stdout.strip())
148
+ if str(decision.get("decision", "")).lower() == "deny":
149
+ reason = decision.get("reason", "hook denied")
150
+ log.info("[hooks] pre_tool_use JSON-denied %s: %s", tool_name, reason)
151
+ return {
152
+ "error": f"Tool denied by hook: {reason}",
153
+ "error_kind": "hook_denied",
154
+ }
155
+ except Exception:
156
+ pass
157
+
158
+ return None # allowed
159
+
160
+
161
+ def run_post_tool_use_hook(
162
+ project_root: Path,
163
+ model: str,
164
+ tool_name: str,
165
+ args: dict[str, Any],
166
+ result: dict[str, Any],
167
+ ) -> None:
168
+ """Run the post_tool_use hook if it exists. Return value is ignored."""
169
+ hook = _find_hook(project_root, "post_tool_use")
170
+ if hook is None:
171
+ return
172
+ payload = {
173
+ "tool": tool_name,
174
+ "args": args,
175
+ "result": result,
176
+ "type": "post_tool_use",
177
+ }
178
+ _run_hook(hook, project_root, model, "post_tool_use", payload, timeout=_TIMEOUT_POST)
179
+
180
+
181
+ def run_session_start_hook(project_root: Path, model: str) -> None:
182
+ """Run the session_start hook if it exists."""
183
+ hook = _find_hook(project_root, "session_start")
184
+ if hook is None:
185
+ return
186
+ _run_hook(hook, project_root, model, "session_start", timeout=10.0)
187
+
188
+
189
+ def run_session_stop_hook(project_root: Path, model: str) -> None:
190
+ """Run the session_stop hook if it exists."""
191
+ hook = _find_hook(project_root, "session_stop")
192
+ if hook is None:
193
+ return
194
+ _run_hook(hook, project_root, model, "session_stop", timeout=10.0)
@@ -172,9 +172,23 @@ def build_extra_tools(cfg: GemCodeConfig) -> list[Any]:
172
172
  """Return ADK tool unions to expose for enabled modalities."""
173
173
  extra: list[Any] = []
174
174
 
175
+ # ── Web search (standalone, no full deep_research needed) ────────────────
176
+ # enable_web_search=True adds google_search alone.
177
+ # enable_deep_research=True adds google_search + url_context + optional maps.
178
+ # If both are on, avoid adding google_search twice.
179
+ web_search_added = False
180
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
181
+ try:
182
+ from google.adk.tools import google_search
183
+ extra.append(google_search)
184
+ web_search_added = True
185
+ except Exception:
186
+ pass
187
+
175
188
  if getattr(cfg, "enable_deep_research", False):
176
189
  from google.adk.tools import google_search, url_context
177
- extra.append(google_search)
190
+ if not web_search_added:
191
+ extra.append(google_search)
178
192
  extra.append(url_context)
179
193
  # Google Maps grounding can be incompatible with other built-in tools
180
194
  # (e.g., google_search) depending on the request/model tooling layer.
@@ -0,0 +1,76 @@
1
+ """
2
+ Per-model token pricing for cost estimation.
3
+
4
+ All prices are USD per 1 million tokens (input / output separately).
5
+ Sources: Google AI Studio / Vertex AI pricing pages as of 2026.
6
+
7
+ Usage:
8
+ from gemcode.pricing import estimate_cost
9
+ usd = estimate_cost("gemini-2.5-flash", input_tokens=5000, output_tokens=1200)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ # Format: model_prefix → (input_$/M, output_$/M)
15
+ # Matches are done by checking if the model id STARTS WITH any key (longest first).
16
+ _PRICING_TABLE: dict[str, tuple[float, float]] = {
17
+ # ── Gemini 2.5 ──────────────────────────────────────────────────────────
18
+ "gemini-2.5-pro": (3.50, 10.50), # standard context ≤200k
19
+ "gemini-2.5-flash": (0.15, 0.60), # standard context
20
+ "gemini-2.5-flash-8b": (0.037, 0.15), # 8B lite variant
21
+ # ── Gemini 2.0 ──────────────────────────────────────────────────────────
22
+ "gemini-2.0-flash": (0.10, 0.40),
23
+ "gemini-2.0-flash-lite": (0.075, 0.30),
24
+ "gemini-2.0-pro": (3.50, 10.50),
25
+ # ── Gemini 1.5 ──────────────────────────────────────────────────────────
26
+ "gemini-1.5-flash": (0.075, 0.30),
27
+ "gemini-1.5-pro": (1.25, 5.00),
28
+ # ── Gemini experimental / preview ───────────────────────────────────────
29
+ "gemini-exp": (0.00, 0.00), # free during preview
30
+ "gemini-3": (0.30, 1.20), # approximate future pricing
31
+ }
32
+
33
+
34
+ def estimate_cost(
35
+ model: str,
36
+ *,
37
+ input_tokens: int = 0,
38
+ output_tokens: int = 0,
39
+ ) -> float | None:
40
+ """
41
+ Return estimated USD cost for a model turn, or None if pricing is unknown.
42
+
43
+ Args:
44
+ model: Model id string (e.g. "gemini-2.5-flash").
45
+ input_tokens: Prompt/input token count.
46
+ output_tokens: Candidates/output token count.
47
+ """
48
+ if not model:
49
+ return None
50
+ model_lower = model.lower().strip()
51
+ # Match longest prefix first for specificity.
52
+ for prefix in sorted(_PRICING_TABLE, key=len, reverse=True):
53
+ if model_lower.startswith(prefix):
54
+ in_rate, out_rate = _PRICING_TABLE[prefix]
55
+ return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
56
+ return None
57
+
58
+
59
+ def format_cost(usd: float | None) -> str:
60
+ """Human-readable cost string, e.g. '$0.0012' or '$1.23'."""
61
+ if usd is None:
62
+ return ""
63
+ if usd < 0.0001:
64
+ return f"<$0.0001"
65
+ if usd < 0.01:
66
+ return f"${usd:.4f}"
67
+ return f"${usd:.3f}"
68
+
69
+
70
+ def format_tokens(n: int) -> str:
71
+ """Format a token count compactly: 1234 → '1.2k', 12345678 → '12.3M'."""
72
+ if n >= 1_000_000:
73
+ return f"{n / 1_000_000:.1f}M"
74
+ if n >= 1_000:
75
+ return f"{n / 1_000:.1f}k"
76
+ return str(n)