gemcode 0.3.29__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.
- {gemcode-0.3.29/src/gemcode.egg-info → gemcode-0.3.30}/PKG-INFO +1 -1
- {gemcode-0.3.29 → gemcode-0.3.30}/pyproject.toml +1 -1
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/agent.py +39 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/callbacks.py +68 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/config.py +15 -0
- gemcode-0.3.30/src/gemcode/hooks.py +194 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/modality_tools.py +15 -1
- gemcode-0.3.30/src/gemcode/pricing.py +76 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/repl_commands.py +28 -1
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/repl_slash.py +42 -2
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/input_handler.py +19 -1
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/scrollback.py +44 -1
- {gemcode-0.3.29 → gemcode-0.3.30/src/gemcode.egg-info}/PKG-INFO +1 -1
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/SOURCES.txt +2 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/LICENSE +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/MANIFEST.in +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/README.md +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/setup.cfg +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/__main__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/audit.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/cli.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/compaction.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/credentials.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/interactions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/invoke.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/kairos_daemon.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/limits.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/paths.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/permissions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/config.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/refine.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/thinking.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/trust.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/version.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/vertex.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/claude_sse_adapter.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_autocompact.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_capability_routing.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_claude_web_adapter_sse.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_cli_init.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_context_budget.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_context_warning.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_credentials.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_kairos_scheduler.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_modality_tools.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_errors.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_routing.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_paths.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_permissions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_repl_commands.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_repl_slash.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_slash_commands.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_thinking_config.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_token_budget.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tools.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_workspace_hints.py +0 -0
|
@@ -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})")
|
|
@@ -151,6 +153,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
|
|
|
151
153
|
"""
|
|
152
154
|
|
|
153
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
|
+
|
|
154
190
|
def _build_computer_use_section(cfg: GemCodeConfig) -> str:
|
|
155
191
|
"""Rich computer use guidance, only injected when enable_computer_use=True."""
|
|
156
192
|
w = getattr(cfg, "_cfg", None)
|
|
@@ -500,6 +536,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
|
|
|
500
536
|
if getattr(cfg, "enable_memory", False):
|
|
501
537
|
base = f"{base}\n\n{_build_memory_section(cfg)}"
|
|
502
538
|
|
|
539
|
+
if getattr(cfg, "plan_mode", False):
|
|
540
|
+
base = f"{base}\n\n{_build_plan_mode_section()}"
|
|
541
|
+
|
|
503
542
|
tool_manifest = build_tool_manifest(cfg)
|
|
504
543
|
if tool_manifest:
|
|
505
544
|
base = f"{base}\n\n{tool_manifest}"
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -101,7 +101,8 @@ def format_memory_lines(cfg: GemCodeConfig) -> list[str]:
|
|
|
101
101
|
|
|
102
102
|
def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
|
|
103
103
|
env_hook = os.environ.get("GEMCODE_POST_TURN_HOOK")
|
|
104
|
-
|
|
104
|
+
hooks_dir = cfg.project_root / ".gemcode" / "hooks"
|
|
105
|
+
default_hook = hooks_dir / "post_turn"
|
|
105
106
|
active: str | None = env_hook
|
|
106
107
|
if not active and _is_executable(default_hook):
|
|
107
108
|
active = str(default_hook)
|
|
@@ -115,6 +116,31 @@ def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
|
|
|
115
116
|
lines.append(f" active_hook: {active}")
|
|
116
117
|
else:
|
|
117
118
|
lines.append(" active_hook: (none — set env or chmod +x .gemcode/hooks/post_turn)")
|
|
119
|
+
|
|
120
|
+
# ── Shell lifecycle hooks (like OpenClaude PreToolUse / PostToolUse) ──────
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append("Shell lifecycle hooks (.gemcode/hooks/):")
|
|
123
|
+
lines.append(" Scripts run at tool/session lifecycle points.")
|
|
124
|
+
lifecycle_hooks = [
|
|
125
|
+
("pre_tool_use", "Run BEFORE each tool call. Non-zero exit → tool denied."),
|
|
126
|
+
("post_tool_use", "Run AFTER each tool call. Informational; return ignored."),
|
|
127
|
+
("session_start", "Run when a GemCode session starts."),
|
|
128
|
+
("session_stop", "Run when a GemCode session ends."),
|
|
129
|
+
]
|
|
130
|
+
for hook_name, description in lifecycle_hooks:
|
|
131
|
+
found = None
|
|
132
|
+
for ext in ("", ".sh", ".py", ".bash"):
|
|
133
|
+
p = hooks_dir / f"{hook_name}{ext}"
|
|
134
|
+
if p.is_file() and _is_executable(p):
|
|
135
|
+
found = p
|
|
136
|
+
break
|
|
137
|
+
status = f"✓ {found}" if found else "✗ not found (create and chmod +x to enable)"
|
|
138
|
+
lines.append(f" {hook_name:20s} {status}")
|
|
139
|
+
lines.append(f" {'':20s} {description}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
lines.append(f" Hook directory: {hooks_dir}")
|
|
142
|
+
lines.append(" Hooks receive JSON on stdin with tool name, args, and result.")
|
|
143
|
+
lines.append(" Set GEMCODE_HOOK_TYPE env var is set for each hook.")
|
|
118
144
|
return lines
|
|
119
145
|
|
|
120
146
|
|
|
@@ -217,4 +243,5 @@ def slash_help_lines() -> list[str]:
|
|
|
217
243
|
" /hooks Show post-turn hook configuration",
|
|
218
244
|
" /kairos How to launch the background parallel job scheduler",
|
|
219
245
|
" /code [on|off] Toggle sandboxed Python executor (ADK BuiltInCodeExecutor)",
|
|
246
|
+
" /plan [on|off] Toggle plan mode — agent plans before executing tools",
|
|
220
247
|
]
|
|
@@ -357,6 +357,32 @@ async def process_repl_slash(
|
|
|
357
357
|
out()
|
|
358
358
|
return ReplSlashResult(skip_model_turn=True)
|
|
359
359
|
|
|
360
|
+
# ── /plan ─────────────────────────────────────────────────────────────────
|
|
361
|
+
if name == "plan":
|
|
362
|
+
args_s = (sc.args or "").strip().lower()
|
|
363
|
+
if args_s in ("on", "enable", "1", "true"):
|
|
364
|
+
cfg.plan_mode = True
|
|
365
|
+
out("Plan mode: ON")
|
|
366
|
+
out("The agent will now write an explicit numbered plan BEFORE executing")
|
|
367
|
+
out("any tools. It will pause for your confirmation before proceeding.")
|
|
368
|
+
out()
|
|
369
|
+
out("Type /plan off to disable.")
|
|
370
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
371
|
+
elif args_s in ("off", "disable", "0", "false"):
|
|
372
|
+
cfg.plan_mode = False
|
|
373
|
+
out("Plan mode: OFF")
|
|
374
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
375
|
+
else:
|
|
376
|
+
status = "ON" if getattr(cfg, "plan_mode", False) else "OFF"
|
|
377
|
+
out(f"Plan mode: {status}")
|
|
378
|
+
out()
|
|
379
|
+
out("When ON, the agent writes a numbered plan and waits for your")
|
|
380
|
+
out("confirmation before executing any file or shell operations.")
|
|
381
|
+
out()
|
|
382
|
+
out("Best for: complex multi-file refactors, migrations, risky changes.")
|
|
383
|
+
out("Toggle: /plan on /plan off")
|
|
384
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
385
|
+
|
|
360
386
|
# ── /code ─────────────────────────────────────────────────────────────────
|
|
361
387
|
if name == "code":
|
|
362
388
|
args_s = (sc.args or "").strip().lower()
|
|
@@ -603,19 +629,23 @@ async def process_repl_slash(
|
|
|
603
629
|
# ── /caps ─────────────────────────────────────────────────────────────────
|
|
604
630
|
if name in ("caps", "capabilities", "capability"):
|
|
605
631
|
args_s = (sc.args or "").strip().lower()
|
|
606
|
-
valid_caps = ("auto", "research", "embeddings", "computer", "all", "none", "reset")
|
|
632
|
+
valid_caps = ("auto", "research", "embeddings", "computer", "search", "all", "none", "reset")
|
|
607
633
|
out("Active capabilities:")
|
|
634
|
+
out(f" web_search: {'on' if getattr(cfg, 'enable_web_search', False) else 'off'}")
|
|
608
635
|
out(f" deep_research: {'on' if cfg.enable_deep_research else 'off'}")
|
|
609
636
|
out(f" embeddings: {'on' if cfg.enable_embeddings else 'off'}")
|
|
610
637
|
out(f" memory: {'on' if cfg.enable_memory else 'off'}")
|
|
611
638
|
out(f" computer_use: {'on' if cfg.enable_computer_use else 'off'}")
|
|
612
639
|
out(f" maps_grounding: {'on' if cfg.enable_maps_grounding else 'off'}")
|
|
640
|
+
out(f" code_executor: {'on' if getattr(cfg, 'enable_code_executor', False) else 'off'}")
|
|
641
|
+
out(f" plan_mode: {'on' if getattr(cfg, 'plan_mode', False) else 'off'}")
|
|
613
642
|
out(f" capability_mode (auto-routing): {cfg.capability_mode}")
|
|
614
643
|
out()
|
|
615
644
|
if not args_s:
|
|
616
645
|
out("Commands:")
|
|
617
646
|
out(" /caps none — turn all off, capability_mode=auto")
|
|
618
|
-
out(" /caps
|
|
647
|
+
out(" /caps search — enable_web_search on (standalone google_search)")
|
|
648
|
+
out(" /caps research — enable_deep_research on (search + url_context)")
|
|
619
649
|
out(" /caps embeddings — enable_embeddings on")
|
|
620
650
|
out(" /caps all — all modalities on")
|
|
621
651
|
out(" /caps reset — reset to startup defaults (all off, auto mode)")
|
|
@@ -626,10 +656,18 @@ async def process_repl_slash(
|
|
|
626
656
|
cfg.enable_embeddings = False
|
|
627
657
|
cfg.enable_computer_use = False
|
|
628
658
|
cfg.enable_maps_grounding = False
|
|
659
|
+
if hasattr(cfg, "enable_web_search"):
|
|
660
|
+
cfg.enable_web_search = False
|
|
629
661
|
cfg.capability_mode = "auto"
|
|
630
662
|
out("capabilities: reset to defaults (all off, auto mode)")
|
|
631
663
|
out()
|
|
632
664
|
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
665
|
+
if args_s == "search":
|
|
666
|
+
if hasattr(cfg, "enable_web_search"):
|
|
667
|
+
cfg.enable_web_search = True
|
|
668
|
+
out("enable_web_search: on (google_search available without full deep_research)")
|
|
669
|
+
out()
|
|
670
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
633
671
|
if args_s == "research":
|
|
634
672
|
cfg.enable_deep_research = True
|
|
635
673
|
out("enable_deep_research: on (runner rebuilding…)")
|
|
@@ -650,6 +688,8 @@ async def process_repl_slash(
|
|
|
650
688
|
cfg.enable_deep_research = True
|
|
651
689
|
cfg.enable_embeddings = True
|
|
652
690
|
cfg.enable_computer_use = True
|
|
691
|
+
if hasattr(cfg, "enable_web_search"):
|
|
692
|
+
cfg.enable_web_search = True
|
|
653
693
|
out("capabilities: all on (runner rebuilding…)")
|
|
654
694
|
out()
|
|
655
695
|
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
@@ -57,6 +57,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
57
57
|
("limits", "Show/set execution limits (max_llm_calls, context, etc.)"),
|
|
58
58
|
("kairos", "Background parallel job scheduler — how to run gemcode kairos"),
|
|
59
59
|
("code", "Toggle sandboxed Python code executor (ADK BuiltInCodeExecutor)"),
|
|
60
|
+
("plan", "Toggle plan mode — agent writes explicit plan before executing tools"),
|
|
60
61
|
("tools", "List all tools and their permission categories"),
|
|
61
62
|
("config", "Show full active configuration (all fields)"),
|
|
62
63
|
("permissions", "Show current permission mode (default / strict / yes)"),
|
|
@@ -114,10 +115,12 @@ class GemCodeInputHandler:
|
|
|
114
115
|
ansi_enabled: bool = True,
|
|
115
116
|
get_model: Callable[[], str] | None = None,
|
|
116
117
|
get_session_id: Callable[[], str] | None = None,
|
|
118
|
+
get_cfg: Callable[[], object | None] | None = None,
|
|
117
119
|
) -> None:
|
|
118
120
|
self._ansi = ansi_enabled
|
|
119
121
|
self._get_model = get_model or (lambda: "gemini")
|
|
120
122
|
self._get_session_id = get_session_id or (lambda: "")
|
|
123
|
+
self._get_cfg = get_cfg or (lambda: None)
|
|
121
124
|
self._session: "PromptSession | None" = None
|
|
122
125
|
|
|
123
126
|
if _PT_AVAILABLE and sys.stdin.isatty() and sys.stdout.isatty():
|
|
@@ -142,15 +145,30 @@ class GemCodeInputHandler:
|
|
|
142
145
|
|
|
143
146
|
get_model = self._get_model
|
|
144
147
|
get_session_id = self._get_session_id
|
|
148
|
+
get_cfg = self._get_cfg
|
|
145
149
|
|
|
146
150
|
def bottom_toolbar() -> HTML:
|
|
147
151
|
model = get_model() or "gemini"
|
|
148
152
|
sid = get_session_id()
|
|
149
153
|
sid_short = sid[:8] if len(sid) >= 8 else sid
|
|
154
|
+
cfg = get_cfg()
|
|
155
|
+
extras = ""
|
|
156
|
+
if cfg is not None:
|
|
157
|
+
flags = []
|
|
158
|
+
if getattr(cfg, "plan_mode", False):
|
|
159
|
+
flags.append("PLAN")
|
|
160
|
+
if getattr(cfg, "enable_code_executor", False):
|
|
161
|
+
flags.append("CODE")
|
|
162
|
+
if getattr(cfg, "enable_deep_research", False):
|
|
163
|
+
flags.append("RESEARCH")
|
|
164
|
+
if getattr(cfg, "enable_computer_use", False):
|
|
165
|
+
flags.append("BROWSER")
|
|
166
|
+
if flags:
|
|
167
|
+
extras = " · " + " ".join(f"[{f}]" for f in flags)
|
|
150
168
|
return HTML(
|
|
151
169
|
f'<style bg="#0d1f2d" fg="#5fafd7"><b> ◆ {model}</b></style>'
|
|
152
170
|
f'<style bg="#0d1f2d" fg="#3d6080"> · session {sid_short}'
|
|
153
|
-
f' · / for commands</style>'
|
|
171
|
+
f'{extras} · / for commands</style>'
|
|
154
172
|
)
|
|
155
173
|
|
|
156
174
|
kb = KeyBindings()
|
|
@@ -204,6 +204,13 @@ async def run_gemcode_scrollback_tui(
|
|
|
204
204
|
load_cli_environment()
|
|
205
205
|
os.environ["GEMCODE_TUI_ACTIVE"] = "1"
|
|
206
206
|
|
|
207
|
+
# ── Session-start hook ──────────────────────────────────────────────────
|
|
208
|
+
try:
|
|
209
|
+
from gemcode.hooks import run_session_start_hook
|
|
210
|
+
run_session_start_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
207
214
|
ansi = _Ansi(
|
|
208
215
|
enabled=(
|
|
209
216
|
sys.stdout.isatty()
|
|
@@ -234,6 +241,7 @@ async def run_gemcode_scrollback_tui(
|
|
|
234
241
|
ansi_enabled=ansi.enabled,
|
|
235
242
|
get_model=lambda: getattr(cfg, "model", "gemini") or "gemini",
|
|
236
243
|
get_session_id=lambda: _current_session_id_holder[0],
|
|
244
|
+
get_cfg=lambda: cfg,
|
|
237
245
|
)
|
|
238
246
|
|
|
239
247
|
async def typewrite(text: str) -> None:
|
|
@@ -437,10 +445,20 @@ async def run_gemcode_scrollback_tui(
|
|
|
437
445
|
prompt = await input_handler.prompt_async()
|
|
438
446
|
except EOFError:
|
|
439
447
|
print("")
|
|
448
|
+
try:
|
|
449
|
+
from gemcode.hooks import run_session_stop_hook
|
|
450
|
+
run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
440
453
|
return
|
|
441
454
|
if not prompt:
|
|
442
455
|
continue
|
|
443
456
|
if prompt in (":q", "quit", "exit", "/exit"):
|
|
457
|
+
try:
|
|
458
|
+
from gemcode.hooks import run_session_stop_hook
|
|
459
|
+
run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
|
|
460
|
+
except Exception:
|
|
461
|
+
pass
|
|
444
462
|
return
|
|
445
463
|
|
|
446
464
|
old_model = getattr(cfg, "model", "")
|
|
@@ -707,7 +725,32 @@ async def run_gemcode_scrollback_tui(
|
|
|
707
725
|
else current_session_id
|
|
708
726
|
)
|
|
709
727
|
model = getattr(cfg, "model", "") or ""
|
|
710
|
-
|
|
728
|
+
# ── Token / cost stats from last turn ──────────────────────────────────
|
|
729
|
+
stats = getattr(cfg, "_last_turn_stats", None)
|
|
730
|
+
token_part = ""
|
|
731
|
+
if stats:
|
|
732
|
+
try:
|
|
733
|
+
from gemcode.pricing import format_cost, format_tokens
|
|
734
|
+
in_tok = stats.get("in", 0) or 0
|
|
735
|
+
out_tok = stats.get("out", 0) or 0
|
|
736
|
+
think_tok = stats.get("think", 0) or 0
|
|
737
|
+
session_total = stats.get("session_total", 0) or 0
|
|
738
|
+
turn_cost = stats.get("turn_cost")
|
|
739
|
+
session_cost = stats.get("session_cost")
|
|
740
|
+
parts_t: list[str] = []
|
|
741
|
+
parts_t.append(f"↑{format_tokens(in_tok)} ↓{format_tokens(out_tok)}")
|
|
742
|
+
if think_tok:
|
|
743
|
+
parts_t.append(f"✦{format_tokens(think_tok)}")
|
|
744
|
+
if turn_cost is not None:
|
|
745
|
+
parts_t.append(format_cost(turn_cost))
|
|
746
|
+
if session_total:
|
|
747
|
+
parts_t.append(f"session {format_tokens(session_total)}")
|
|
748
|
+
if session_cost and session_cost > 0.0001:
|
|
749
|
+
parts_t.append(f"total {format_cost(session_cost)}")
|
|
750
|
+
token_part = " · " + " · ".join(parts_t)
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
print(f"{ansi.dim} · {model} · session {sid}{token_part}{ansi.reset}")
|
|
711
754
|
if os.environ.get("GEMCODE_TUI_TURN_RULE", "1").lower() in (
|
|
712
755
|
"1",
|
|
713
756
|
"true",
|
|
@@ -16,6 +16,7 @@ src/gemcode/context_budget.py
|
|
|
16
16
|
src/gemcode/context_warning.py
|
|
17
17
|
src/gemcode/credentials.py
|
|
18
18
|
src/gemcode/hitl_session.py
|
|
19
|
+
src/gemcode/hooks.py
|
|
19
20
|
src/gemcode/interactions.py
|
|
20
21
|
src/gemcode/invoke.py
|
|
21
22
|
src/gemcode/kairos_daemon.py
|
|
@@ -28,6 +29,7 @@ src/gemcode/model_errors.py
|
|
|
28
29
|
src/gemcode/model_routing.py
|
|
29
30
|
src/gemcode/paths.py
|
|
30
31
|
src/gemcode/permissions.py
|
|
32
|
+
src/gemcode/pricing.py
|
|
31
33
|
src/gemcode/prompt_suggestions.py
|
|
32
34
|
src/gemcode/refine.py
|
|
33
35
|
src/gemcode/repl_commands.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|