gemcode 0.3.29__tar.gz → 0.3.31__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 (119) hide show
  1. {gemcode-0.3.29/src/gemcode.egg-info → gemcode-0.3.31}/PKG-INFO +1 -1
  2. {gemcode-0.3.29 → gemcode-0.3.31}/pyproject.toml +1 -1
  3. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/agent.py +135 -6
  4. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/callbacks.py +68 -0
  5. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/cli.py +4 -14
  6. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/config.py +15 -0
  7. gemcode-0.3.31/src/gemcode/hooks.py +194 -0
  8. gemcode-0.3.31/src/gemcode/mcp_loader.py +147 -0
  9. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/modality_tools.py +15 -1
  10. gemcode-0.3.31/src/gemcode/openapi_loader.py +127 -0
  11. gemcode-0.3.31/src/gemcode/pricing.py +76 -0
  12. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/repl_commands.py +36 -1
  13. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/repl_slash.py +153 -2
  14. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/session_runtime.py +21 -0
  15. gemcode-0.3.31/src/gemcode/tools/notes.py +106 -0
  16. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tui/input_handler.py +22 -1
  17. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tui/scrollback.py +44 -1
  18. {gemcode-0.3.29 → gemcode-0.3.31/src/gemcode.egg-info}/PKG-INFO +1 -1
  19. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode.egg-info/SOURCES.txt +4 -0
  20. gemcode-0.3.29/src/gemcode/mcp_loader.py +0 -57
  21. {gemcode-0.3.29 → gemcode-0.3.31}/LICENSE +0 -0
  22. {gemcode-0.3.29 → gemcode-0.3.31}/MANIFEST.in +0 -0
  23. {gemcode-0.3.29 → gemcode-0.3.31}/README.md +0 -0
  24. {gemcode-0.3.29 → gemcode-0.3.31}/setup.cfg +0 -0
  25. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/__init__.py +0 -0
  26. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/__main__.py +0 -0
  27. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/audit.py +0 -0
  28. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/autocompact.py +0 -0
  29. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/capability_routing.py +0 -0
  30. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/compaction.py +0 -0
  31. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/computer_use/__init__.py +0 -0
  32. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/computer_use/browser_computer.py +0 -0
  33. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/context_budget.py +0 -0
  34. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/context_warning.py +0 -0
  35. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/credentials.py +0 -0
  36. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/hitl_session.py +0 -0
  37. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/interactions.py +0 -0
  38. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/invoke.py +0 -0
  39. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/kairos_daemon.py +0 -0
  40. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/limits.py +0 -0
  41. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/live_audio_engine.py +0 -0
  42. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/logging_config.py +0 -0
  43. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/memory/__init__.py +0 -0
  44. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/memory/embedding_memory_service.py +0 -0
  45. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/memory/file_memory_service.py +0 -0
  46. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/model_errors.py +0 -0
  47. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/model_routing.py +0 -0
  48. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/paths.py +0 -0
  49. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/permissions.py +0 -0
  50. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/plugins/__init__.py +0 -0
  51. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  52. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  53. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/prompt_suggestions.py +0 -0
  54. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/__init__.py +0 -0
  55. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/config.py +0 -0
  56. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/deps.py +0 -0
  57. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/engine.py +0 -0
  58. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/stop_hooks.py +0 -0
  59. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/token_budget.py +0 -0
  60. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/query/transitions.py +0 -0
  61. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/refine.py +0 -0
  62. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/slash_commands.py +0 -0
  63. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/thinking.py +0 -0
  64. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tool_prompt_manifest.py +0 -0
  65. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tool_registry.py +0 -0
  66. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/__init__.py +0 -0
  67. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/bash.py +0 -0
  68. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/browser.py +0 -0
  69. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/edit.py +0 -0
  70. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/filesystem.py +0 -0
  71. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/search.py +0 -0
  72. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/shell.py +0 -0
  73. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/shell_gate.py +0 -0
  74. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/subtask.py +0 -0
  75. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/think.py +0 -0
  76. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/todo.py +0 -0
  77. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools/web.py +0 -0
  78. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tools_inspector.py +0 -0
  79. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/trust.py +0 -0
  80. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tui/spinner.py +0 -0
  81. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tui/welcome_banner.py +0 -0
  82. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/tui/welcome_rich.py +0 -0
  83. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/version.py +0 -0
  84. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/vertex.py +0 -0
  85. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/web/__init__.py +0 -0
  86. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/web/claude_sse_adapter.py +0 -0
  87. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/web/terminal_repl.py +0 -0
  88. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode/workspace_hints.py +0 -0
  89. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode.egg-info/dependency_links.txt +0 -0
  90. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode.egg-info/entry_points.txt +0 -0
  91. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode.egg-info/requires.txt +0 -0
  92. {gemcode-0.3.29 → gemcode-0.3.31}/src/gemcode.egg-info/top_level.txt +0 -0
  93. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_agent_instruction.py +0 -0
  94. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_autocompact.py +0 -0
  95. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_capability_routing.py +0 -0
  96. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_claude_web_adapter_sse.py +0 -0
  97. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_cli_init.py +0 -0
  98. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_computer_use_permissions.py +0 -0
  99. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_context_budget.py +0 -0
  100. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_context_warning.py +0 -0
  101. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_credentials.py +0 -0
  102. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_interactive_permission_ask.py +0 -0
  103. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_kairos_scheduler.py +0 -0
  104. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_modality_tools.py +0 -0
  105. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_model_error_retry.py +0 -0
  106. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_model_errors.py +0 -0
  107. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_model_routing.py +0 -0
  108. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_paths.py +0 -0
  109. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_permissions.py +0 -0
  110. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_prompt_suggestions.py +0 -0
  111. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_repl_commands.py +0 -0
  112. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_repl_slash.py +0 -0
  113. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_slash_commands.py +0 -0
  114. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_thinking_config.py +0 -0
  115. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_token_budget.py +0 -0
  116. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_tool_context_circulation.py +0 -0
  117. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_tools.py +0 -0
  118. {gemcode-0.3.29 → gemcode-0.3.31}/tests/test_tools_inspector.py +0 -0
  119. {gemcode-0.3.29 → gemcode-0.3.31}/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.29
3
+ Version: 0.3.31
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.29"
7
+ version = "0.3.31"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -49,11 +49,77 @@ def _chain_before_model_callbacks(*callbacks):
49
49
 
50
50
 
51
51
  def _load_gemini_md(project_root: Path) -> str:
52
- for name in ("GEMINI.md", "gemini.md"):
53
- p = project_root / name
54
- if p.is_file():
55
- return p.read_text(encoding="utf-8", errors="replace")[:50_000]
56
- return ""
52
+ """
53
+ Load GEMINI.md / .gemcode/NOTES.md from a Claude Code–style hierarchy.
54
+
55
+ Priority (later entries override earlier ones, all are concatenated):
56
+ 1. ~/.gemcode/GEMINI.md — user-global instructions (all projects)
57
+ 2. Walk UP from project_root: each directory's GEMINI.md / .gemcode/GEMINI.md
58
+ (org-level files at higher dirs, project-level at project_root)
59
+ 3. project_root/GEMINI.md — the primary project instructions
60
+ 4. project_root/.gemcode/GEMINI.md — alternative location
61
+ 5. project_root/.gemcode/notes.md — agent auto-generated notes (read-only context)
62
+
63
+ Max total: 80,000 chars. Each file is capped at 30,000 chars.
64
+ HTML comments (<!-- ... -->) are stripped before injection (saves tokens).
65
+ """
66
+ import re
67
+
68
+ _NAMES = ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md")
69
+ _FILE_CAP = 30_000
70
+ _TOTAL_CAP = 80_000
71
+ _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
72
+
73
+ def _read(p: Path) -> str:
74
+ if not p.is_file():
75
+ return ""
76
+ try:
77
+ raw = p.read_text(encoding="utf-8", errors="replace")[:_FILE_CAP]
78
+ # Strip HTML comments (like Claude Code does — saves tokens)
79
+ return _COMMENT_RE.sub("", raw).strip()
80
+ except OSError:
81
+ return ""
82
+
83
+ seen: set[Path] = set()
84
+ sections: list[str] = []
85
+
86
+ def _add(p: Path, label: str | None = None) -> None:
87
+ resolved = p.resolve()
88
+ if resolved in seen:
89
+ return
90
+ seen.add(resolved)
91
+ text = _read(p)
92
+ if text:
93
+ sections.append(f"<!-- {label or str(p)} -->\n{text}" if label else text)
94
+
95
+ # 1. User-global: ~/.gemcode/GEMINI.md
96
+ user_global = Path.home() / ".gemcode" / "GEMINI.md"
97
+ _add(user_global, "user-global (~/.gemcode/GEMINI.md)")
98
+
99
+ # 2. Walk UP from project_root to filesystem root — loads org / monorepo-level instructions
100
+ walk = project_root.resolve()
101
+ ancestors = []
102
+ while walk != walk.parent:
103
+ walk = walk.parent
104
+ if walk == Path.home() or walk == Path("/"):
105
+ break
106
+ ancestors.append(walk)
107
+ # Walk outer→inner (org first, closer dirs later — later = higher priority)
108
+ for ancestor in reversed(ancestors):
109
+ for name in _NAMES:
110
+ _add(ancestor / name)
111
+
112
+ # 3+4. Project-root level instructions (primary location)
113
+ for name in ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md", ".gemcode/gemini.md"):
114
+ _add(project_root / name)
115
+
116
+ # 5. Agent-generated notes (informational context, not instructions)
117
+ notes = project_root / ".gemcode" / "notes.md"
118
+ if notes.is_file():
119
+ _add(notes, "agent notes (.gemcode/notes.md)")
120
+
121
+ combined = "\n\n---\n\n".join(s for s in sections if s.strip())
122
+ return combined[:_TOTAL_CAP]
57
123
 
58
124
 
59
125
  def _build_runtime_facts(cfg: GemCodeConfig) -> str:
@@ -66,6 +132,8 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
66
132
 
67
133
  # ── Active capabilities ──────────────────────────────────────────────────
68
134
  caps: list[str] = []
135
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
136
+ caps.append("web_search ON (tool: google_search — standalone search without full deep_research)")
69
137
  if getattr(cfg, "enable_deep_research", False):
70
138
  dr_extras = " + google_maps_grounding" if getattr(cfg, "enable_maps_grounding", False) else ""
71
139
  caps.append(f"deep_research ON (tools: google_search, url_context{dr_extras})")
@@ -151,6 +219,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
151
219
  """
152
220
 
153
221
 
222
+ def _build_plan_mode_section() -> str:
223
+ """Injected when plan_mode=True — instructs agent to write explicit plans first."""
224
+ return """
225
+ ## PLAN MODE IS ACTIVE
226
+
227
+ You are currently in **Plan Mode**. Before executing ANY tools that modify files or run shell commands, you MUST:
228
+
229
+ 1. **Write a numbered plan** in your response text — list every step you intend to take.
230
+ Example:
231
+ ```
232
+ Plan:
233
+ 1. Read src/auth/login.ts to understand the current flow
234
+ 2. Read src/types/user.ts for the User interface
235
+ 3. Add `lastLogin: Date` field to User interface
236
+ 4. Update login handler to set lastLogin on successful auth
237
+ 5. Run `npm run build` to verify no TypeScript errors
238
+ ```
239
+
240
+ 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.
241
+
242
+ 3. **Stick to the plan** — if you discover the plan needs changing mid-execution, note the update before proceeding.
243
+
244
+ 4. **Report completion** against the plan — when done, confirm each step as completed.
245
+
246
+ **Why plan mode?**
247
+ - Prevents unintended side effects from premature tool execution
248
+ - Gives you and the user visibility into the full scope before any changes
249
+ - Makes complex multi-file tasks reviewable and reversible
250
+ - Catches scope creep early
251
+
252
+ **To turn off plan mode**, type `/plan off` at the prompt.
253
+ """
254
+
255
+
154
256
  def _build_computer_use_section(cfg: GemCodeConfig) -> str:
155
257
  """Rich computer use guidance, only injected when enable_computer_use=True."""
156
258
  w = getattr(cfg, "_cfg", None)
@@ -491,7 +593,23 @@ For tasks where quality matters:
491
593
  - Prefer small, testable, accurate changes over broad rewrites.
492
594
 
493
595
  ## Workspace scope
494
- All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`."""
596
+ All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`.
597
+
598
+ ## Agent notes (.gemcode/notes.md)
599
+ You have two tools to persist project insights across sessions, like Claude Code's auto-memory:
600
+
601
+ - **`append_project_note(note)`** — write a note to `.gemcode/notes.md`. Use this proactively when you discover something worth remembering:
602
+ - Build/test/lint commands you discover ("Build: `npm run build` — requires Node 20")
603
+ - Key file locations ("Auth middleware: `src/middleware/auth.ts`")
604
+ - Known issues or patterns ("DB migrations: always run `prisma db push` after schema changes")
605
+ - User workflow preferences ("User prefers running tests before committing")
606
+ - Architecture decisions or tricky patterns
607
+
608
+ Call this **immediately** when you discover something useful — not just at the end of tasks.
609
+ Notes are loaded at session start so future sessions inherit this knowledge.
610
+
611
+ - **`read_project_notes()`** — read current notes before starting a new project task.
612
+ If notes exist and you haven't read them yet, read them first to avoid re-discovering known information."""
495
613
 
496
614
  # Inject capability-specific strategy sections only when those caps are on.
497
615
  if getattr(cfg, "enable_computer_use", False):
@@ -500,6 +618,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
500
618
  if getattr(cfg, "enable_memory", False):
501
619
  base = f"{base}\n\n{_build_memory_section(cfg)}"
502
620
 
621
+ if getattr(cfg, "plan_mode", False):
622
+ base = f"{base}\n\n{_build_plan_mode_section()}"
623
+
503
624
  tool_manifest = build_tool_manifest(cfg)
504
625
  if tool_manifest:
505
626
  base = f"{base}\n\n{tool_manifest}"
@@ -551,6 +672,14 @@ def build_root_agent(
551
672
  except Exception:
552
673
  pass
553
674
 
675
+ # Agent auto-notes: write project insights to .gemcode/notes.md (Claude Code MEMORY.md equivalent)
676
+ try:
677
+ from gemcode.tools.notes import build_notes_tools
678
+ notes_tools = build_notes_tools(cfg.project_root)
679
+ tools = [*tools, *notes_tools]
680
+ except Exception:
681
+ pass
682
+
554
683
  if extra_tools:
555
684
  tools = [*tools, *extra_tools]
556
685
 
@@ -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:
@@ -151,13 +151,8 @@ async def _run_prompt(
151
151
  _maybe_prompt_google_api_key()
152
152
  require_google_api_key()
153
153
  _initialize_gemcode_project(cfg)
154
- extra: list = []
155
- if use_mcp:
156
- from gemcode.mcp_loader import load_mcp_toolsets
157
-
158
- extra = load_mcp_toolsets(cfg)
159
-
160
- runner = create_runner(cfg, extra_tools=extra or None)
154
+ # MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
155
+ runner = create_runner(cfg, extra_tools=None)
161
156
  try:
162
157
  collected = await run_turn(
163
158
  runner,
@@ -184,13 +179,8 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
184
179
  require_google_api_key()
185
180
  _initialize_gemcode_project(cfg)
186
181
 
187
- extra: list = []
188
- if use_mcp:
189
- from gemcode.mcp_loader import load_mcp_toolsets
190
-
191
- extra = load_mcp_toolsets(cfg)
192
-
193
- runner = create_runner(cfg, extra_tools=extra or None)
182
+ # MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
183
+ runner = create_runner(cfg, extra_tools=None)
194
184
  try:
195
185
  # For CLI UX, show concise tool summaries (helps users see what ran).
196
186
  if os.environ.get("GEMCODE_EMIT_TOOL_USE_SUMMARIES") is None:
@@ -267,6 +267,21 @@ class GemCodeConfig:
267
267
  default_factory=lambda: _truthy_env("GEMCODE_ENABLE_ARTIFACTS", default=True)
268
268
  )
269
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
+
270
285
  def __post_init__(self) -> None:
271
286
  self.project_root = self.project_root.resolve()
272
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)