gemcode 0.3.110__tar.gz → 0.3.111__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.110/src/gemcode.egg-info → gemcode-0.3.111}/PKG-INFO +1 -1
- {gemcode-0.3.110 → gemcode-0.3.111}/pyproject.toml +1 -1
- gemcode-0.3.111/src/gemcode/automations.py +198 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/cli.py +27 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_daemon.py +70 -1
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/repl_commands.py +4 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/repl_slash.py +251 -0
- {gemcode-0.3.110 → gemcode-0.3.111/src/gemcode.egg-info}/PKG-INFO +1 -1
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/SOURCES.txt +2 -0
- gemcode-0.3.111/tests/test_automations.py +43 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/LICENSE +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/MANIFEST.in +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/README.md +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/setup.cfg +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/__main__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/agent.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/audit.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/autotune.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/compaction.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/config.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/credentials.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/hooks.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/interactions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/invoke.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/learning.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/limits.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/org.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/paths.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/permissions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/pricing.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/config.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/refine.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/rules.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_store.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/skills.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/thinking.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/trust.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/scrollback.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/version.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/vertex.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/wal.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/sse_adapter.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_add_dir.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_autocompact.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_capability_routing.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_cli_init.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_context_budget.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_context_warning.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_credentials.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_modality_tools.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_errors.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_routing.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_paths.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_permissions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_repl_commands.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_repl_slash.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_skills.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_slash_commands.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_thinking_config.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_token_budget.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tools.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_workspace_hints.py +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AutomationTrigger:
|
|
12
|
+
kind: str # interval|cron|daily
|
|
13
|
+
every_seconds: int | None = None
|
|
14
|
+
cron: str | None = None
|
|
15
|
+
at_hhmm: str | None = None
|
|
16
|
+
|
|
17
|
+
def key(self) -> str:
|
|
18
|
+
if self.kind == "interval":
|
|
19
|
+
return f"interval:{self.every_seconds}"
|
|
20
|
+
if self.kind == "cron":
|
|
21
|
+
return f"cron:{self.cron}"
|
|
22
|
+
if self.kind == "daily":
|
|
23
|
+
return f"daily:{self.at_hhmm}"
|
|
24
|
+
return self.kind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Automation:
|
|
29
|
+
name: str
|
|
30
|
+
prompt: str
|
|
31
|
+
priority: int = 0
|
|
32
|
+
enabled: bool = True
|
|
33
|
+
session_id: str | None = None
|
|
34
|
+
triggers: tuple[AutomationTrigger, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def automations_dir(project_root: Path) -> Path:
|
|
38
|
+
return project_root / ".gemcode" / "automations"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def automations_state_path(project_root: Path) -> Path:
|
|
42
|
+
return automations_dir(project_root) / "state.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_automations(project_root: Path) -> list[Automation]:
|
|
46
|
+
root = automations_dir(project_root)
|
|
47
|
+
if not root.is_dir():
|
|
48
|
+
return []
|
|
49
|
+
out: list[Automation] = []
|
|
50
|
+
for p in sorted(root.glob("*.json")):
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
53
|
+
except Exception:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
a = _parse_automation(data)
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
out.append(a)
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_automation(data: dict[str, Any]) -> Automation:
|
|
64
|
+
name = str(data.get("name") or "").strip()
|
|
65
|
+
prompt = str(data.get("prompt") or "").strip()
|
|
66
|
+
if not name or not prompt:
|
|
67
|
+
raise ValueError("missing name/prompt")
|
|
68
|
+
enabled = bool(data.get("enabled", True))
|
|
69
|
+
priority = int(data.get("priority") or 0)
|
|
70
|
+
session_id = (str(data.get("session_id")).strip() if data.get("session_id") else None)
|
|
71
|
+
|
|
72
|
+
triggers_raw = data.get("triggers") or []
|
|
73
|
+
if isinstance(triggers_raw, dict):
|
|
74
|
+
triggers_raw = [triggers_raw]
|
|
75
|
+
triggers: list[AutomationTrigger] = []
|
|
76
|
+
for t in triggers_raw:
|
|
77
|
+
if not isinstance(t, dict):
|
|
78
|
+
continue
|
|
79
|
+
kind = str(t.get("kind") or t.get("type") or "").strip().lower()
|
|
80
|
+
if kind in ("interval", "every"):
|
|
81
|
+
every = int(t.get("every_seconds") or t.get("every") or 0)
|
|
82
|
+
if every <= 0:
|
|
83
|
+
continue
|
|
84
|
+
triggers.append(AutomationTrigger(kind="interval", every_seconds=every))
|
|
85
|
+
continue
|
|
86
|
+
if kind == "hourly":
|
|
87
|
+
triggers.append(AutomationTrigger(kind="interval", every_seconds=3600))
|
|
88
|
+
continue
|
|
89
|
+
if kind in ("nightly", "daily"):
|
|
90
|
+
at = str(t.get("at") or "02:00").strip()
|
|
91
|
+
triggers.append(AutomationTrigger(kind="daily", at_hhmm=at))
|
|
92
|
+
continue
|
|
93
|
+
if kind == "cron":
|
|
94
|
+
cron = str(t.get("cron") or "").strip()
|
|
95
|
+
if not cron:
|
|
96
|
+
continue
|
|
97
|
+
triggers.append(AutomationTrigger(kind="cron", cron=cron))
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
return Automation(
|
|
101
|
+
name=name,
|
|
102
|
+
prompt=prompt,
|
|
103
|
+
priority=priority,
|
|
104
|
+
enabled=enabled,
|
|
105
|
+
session_id=session_id,
|
|
106
|
+
triggers=tuple(triggers),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_automation_state(project_root: Path) -> dict[str, float]:
|
|
111
|
+
p = automations_state_path(project_root)
|
|
112
|
+
if not p.is_file():
|
|
113
|
+
return {}
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
return {str(k): float(v) for k, v in data.items()}
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_automation_state(project_root: Path, state: dict[str, float]) -> None:
|
|
124
|
+
d = automations_dir(project_root)
|
|
125
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
p = automations_state_path(project_root)
|
|
127
|
+
try:
|
|
128
|
+
p.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_due(*, now_s: float, last_s: float | None, trig: AutomationTrigger) -> bool:
|
|
134
|
+
if trig.kind == "interval":
|
|
135
|
+
if not trig.every_seconds or trig.every_seconds <= 0:
|
|
136
|
+
return False
|
|
137
|
+
if last_s is None:
|
|
138
|
+
return True
|
|
139
|
+
return (now_s - last_s) >= float(trig.every_seconds)
|
|
140
|
+
if trig.kind == "daily":
|
|
141
|
+
at = trig.at_hhmm or "02:00"
|
|
142
|
+
try:
|
|
143
|
+
hh, mm = at.split(":", 1)
|
|
144
|
+
h = int(hh)
|
|
145
|
+
m = int(mm)
|
|
146
|
+
if not (0 <= h <= 23 and 0 <= m <= 59):
|
|
147
|
+
return False
|
|
148
|
+
except Exception:
|
|
149
|
+
return False
|
|
150
|
+
# Compute today's fire time in local epoch seconds.
|
|
151
|
+
lt = time.localtime(now_s)
|
|
152
|
+
fire_today = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, h, m, 0, lt.tm_wday, lt.tm_yday, lt.tm_isdst))
|
|
153
|
+
# If we already passed today's fire time, next is tomorrow.
|
|
154
|
+
fire_s = fire_today if now_s >= fire_today else fire_today - 86400.0
|
|
155
|
+
# Due if we crossed the boundary since last_s.
|
|
156
|
+
if last_s is None:
|
|
157
|
+
return now_s >= fire_today
|
|
158
|
+
return last_s < fire_today <= now_s
|
|
159
|
+
if trig.kind == "cron":
|
|
160
|
+
return _cron_due(now_s=now_s, last_s=last_s, cron=str(trig.cron or ""))
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cron_due(*, now_s: float, last_s: float | None, cron: str) -> bool:
|
|
165
|
+
# Minimal cron: "M H * * *" with *, */N, or integer for M/H.
|
|
166
|
+
parts = (cron or "").split()
|
|
167
|
+
if len(parts) != 5:
|
|
168
|
+
return False
|
|
169
|
+
m_s, h_s, dom, mon, dow = parts
|
|
170
|
+
if dom != "*" or mon != "*" or dow != "*":
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def _match(field: str, val: int, *, min_v: int, max_v: int) -> bool:
|
|
174
|
+
if field == "*":
|
|
175
|
+
return True
|
|
176
|
+
if field.startswith("*/"):
|
|
177
|
+
try:
|
|
178
|
+
step = int(field[2:])
|
|
179
|
+
if step <= 0:
|
|
180
|
+
return False
|
|
181
|
+
return (val - min_v) % step == 0
|
|
182
|
+
except Exception:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
x = int(field)
|
|
186
|
+
return x == val and min_v <= x <= max_v
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
lt = time.localtime(now_s)
|
|
191
|
+
if not (_match(m_s, lt.tm_min, min_v=0, max_v=59) and _match(h_s, lt.tm_hour, min_v=0, max_v=23)):
|
|
192
|
+
return False
|
|
193
|
+
# Trigger only once per matching minute.
|
|
194
|
+
minute_start = now_s - float(lt.tm_sec)
|
|
195
|
+
if last_s is None:
|
|
196
|
+
return True
|
|
197
|
+
return last_s < minute_start <= now_s
|
|
198
|
+
|
|
@@ -977,6 +977,23 @@ def main() -> None:
|
|
|
977
977
|
metavar="N",
|
|
978
978
|
help="Cap model↔tool iterations for each job message (ADK RunConfig.max_llm_calls).",
|
|
979
979
|
)
|
|
980
|
+
kaira_parser.add_argument(
|
|
981
|
+
"--automations",
|
|
982
|
+
action="store_true",
|
|
983
|
+
help="Enable local scheduled automations from .gemcode/automations/*.json.",
|
|
984
|
+
)
|
|
985
|
+
kaira_parser.add_argument(
|
|
986
|
+
"--heartbeat-every-s",
|
|
987
|
+
type=int,
|
|
988
|
+
default=0,
|
|
989
|
+
metavar="N",
|
|
990
|
+
help="Optional heartbeat job interval (seconds). Enqueues heartbeat prompt repeatedly.",
|
|
991
|
+
)
|
|
992
|
+
kaira_parser.add_argument(
|
|
993
|
+
"--heartbeat-prompt",
|
|
994
|
+
default=None,
|
|
995
|
+
help="Prompt text for heartbeat jobs (used with --heartbeat-every-s).",
|
|
996
|
+
)
|
|
980
997
|
|
|
981
998
|
args = kaira_parser.parse_args(sys.argv[2:])
|
|
982
999
|
load_cli_environment()
|
|
@@ -1013,6 +1030,16 @@ def main() -> None:
|
|
|
1013
1030
|
if args.max_llm_calls is not None:
|
|
1014
1031
|
cfg.max_llm_calls = args.max_llm_calls
|
|
1015
1032
|
|
|
1033
|
+
# Local automations / heartbeat configuration (implemented in KairaDaemon loop).
|
|
1034
|
+
if getattr(args, "automations", False):
|
|
1035
|
+
os.environ["GEMCODE_AUTOMATIONS"] = "1"
|
|
1036
|
+
hb_every = int(getattr(args, "heartbeat_every_s", 0) or 0)
|
|
1037
|
+
if hb_every > 0:
|
|
1038
|
+
os.environ["GEMCODE_AUTOMATIONS"] = "1"
|
|
1039
|
+
os.environ["GEMCODE_KAIRA_HEARTBEAT_EVERY_S"] = str(hb_every)
|
|
1040
|
+
if getattr(args, "heartbeat_prompt", None):
|
|
1041
|
+
os.environ["GEMCODE_KAIRA_HEARTBEAT_PROMPT"] = str(args.heartbeat_prompt)
|
|
1042
|
+
|
|
1016
1043
|
_maybe_prompt_trust(cfg)
|
|
1017
1044
|
_maybe_prompt_google_api_key()
|
|
1018
1045
|
require_google_api_key()
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import copy
|
|
5
5
|
import sys
|
|
6
|
+
import time
|
|
6
7
|
import uuid
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from typing import Awaitable, Callable
|
|
@@ -222,6 +223,65 @@ class KairaDaemon:
|
|
|
222
223
|
# We'll implement dynamic resizing once the rest of the control plane is stable.
|
|
223
224
|
return int(self.concurrency)
|
|
224
225
|
|
|
226
|
+
async def _automations_loop(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
session_id: str,
|
|
230
|
+
heartbeat_every_s: int | None = None,
|
|
231
|
+
heartbeat_prompt: str | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Run saved scheduled automations from `.gemcode/automations/*.json`.
|
|
235
|
+
|
|
236
|
+
This is a simple local scheduler. It is intentionally conservative:
|
|
237
|
+
- interval triggers can be as fast as seconds
|
|
238
|
+
- cron/daily triggers are minute-level
|
|
239
|
+
"""
|
|
240
|
+
import os
|
|
241
|
+
|
|
242
|
+
from gemcode.automations import (
|
|
243
|
+
is_due,
|
|
244
|
+
load_automation_state,
|
|
245
|
+
load_automations,
|
|
246
|
+
save_automation_state,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
state = load_automation_state(self.cfg.project_root)
|
|
250
|
+
hb_last: float | None = None
|
|
251
|
+
while not self._stop_event.is_set():
|
|
252
|
+
try:
|
|
253
|
+
if os.environ.get("GEMCODE_AUTOMATIONS", "0").strip().lower() not in ("1", "true", "yes", "on"):
|
|
254
|
+
await asyncio.sleep(1.0)
|
|
255
|
+
continue
|
|
256
|
+
now_s = time.time()
|
|
257
|
+
|
|
258
|
+
# Heartbeat (ephemeral; CLI-configured).
|
|
259
|
+
if heartbeat_every_s and heartbeat_every_s > 0:
|
|
260
|
+
if hb_last is None or (now_s - hb_last) >= float(heartbeat_every_s):
|
|
261
|
+
hb_last = now_s
|
|
262
|
+
p = (heartbeat_prompt or "Heartbeat: summarize running jobs and system status.").strip()
|
|
263
|
+
if p:
|
|
264
|
+
self.enqueue_prompt(prompt=p, priority=self.default_priority, session_id=session_id)
|
|
265
|
+
|
|
266
|
+
autos = load_automations(self.cfg.project_root)
|
|
267
|
+
changed = False
|
|
268
|
+
for a in autos:
|
|
269
|
+
if not a.enabled:
|
|
270
|
+
continue
|
|
271
|
+
for trig in a.triggers:
|
|
272
|
+
key = f"{a.name}:{trig.key()}"
|
|
273
|
+
last_s = state.get(key)
|
|
274
|
+
if is_due(now_s=now_s, last_s=last_s, trig=trig):
|
|
275
|
+
state[key] = now_s
|
|
276
|
+
changed = True
|
|
277
|
+
sid = a.session_id or session_id
|
|
278
|
+
self.enqueue_prompt(prompt=a.prompt, priority=a.priority, session_id=sid)
|
|
279
|
+
if changed:
|
|
280
|
+
save_automation_state(self.cfg.project_root, state)
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
await asyncio.sleep(5.0)
|
|
284
|
+
|
|
225
285
|
def enqueue_prompt(
|
|
226
286
|
self,
|
|
227
287
|
*,
|
|
@@ -667,13 +727,22 @@ class KairaDaemon:
|
|
|
667
727
|
self._ipc = None
|
|
668
728
|
print(f"[kaira] ipc disabled: {e}", file=sys.stderr, flush=True)
|
|
669
729
|
|
|
730
|
+
import os as _os
|
|
731
|
+
|
|
670
732
|
scheduler_task = asyncio.create_task(self._scheduler_loop())
|
|
733
|
+
automations_task = asyncio.create_task(
|
|
734
|
+
self._automations_loop(
|
|
735
|
+
session_id=session_id,
|
|
736
|
+
heartbeat_every_s=int(_os.environ.get("GEMCODE_KAIRA_HEARTBEAT_EVERY_S", "0") or "0") or None,
|
|
737
|
+
heartbeat_prompt=_os.environ.get("GEMCODE_KAIRA_HEARTBEAT_PROMPT", None),
|
|
738
|
+
)
|
|
739
|
+
)
|
|
671
740
|
stdin_task = None
|
|
672
741
|
if enable_stdin:
|
|
673
742
|
stdin_task = asyncio.create_task(self._stdin_loop(session_id=session_id))
|
|
674
743
|
|
|
675
744
|
# Wait for either scheduler to stop (shouldn't happen) or stdin loop to end.
|
|
676
|
-
wait_set = {scheduler_task}
|
|
745
|
+
wait_set = {scheduler_task, automations_task}
|
|
677
746
|
if stdin_task is not None:
|
|
678
747
|
wait_set.add(stdin_task)
|
|
679
748
|
done, pending = await asyncio.wait(
|
|
@@ -381,6 +381,10 @@ def slash_help_lines() -> list[str]:
|
|
|
381
381
|
" /mcp MCP status (reads .gemcode/mcp.json; shows loaded toolsets)",
|
|
382
382
|
" /mcp list List configured MCP servers",
|
|
383
383
|
" /mcp reload Rebuild runner to reload MCP toolsets",
|
|
384
|
+
" /automations Local scheduled automations (Kaira) + heartbeat",
|
|
385
|
+
" /automations list List .gemcode/automations/*.json",
|
|
386
|
+
" /automations run <n> Enqueue an automation now (needs Kaira IPC running)",
|
|
387
|
+
" /afc AFC prompt defaults (avoid afc> prompt)",
|
|
384
388
|
" /eval [llm] Run tools_smoke (+ pytest if tests/ exist); optional LLM goldens",
|
|
385
389
|
" /autotune init <tag> Git branch autotune/<tag> for experiment tracking",
|
|
386
390
|
" /autotune eval [llm] Eval + append .gemcode/evals/autotune_ledger.jsonl",
|
|
@@ -1336,6 +1336,257 @@ async def process_repl_slash(
|
|
|
1336
1336
|
out()
|
|
1337
1337
|
return ReplSlashResult(skip_model_turn=True)
|
|
1338
1338
|
|
|
1339
|
+
# ── /automations (local scheduled jobs for Kaira) ──────────────────────────
|
|
1340
|
+
if name in ("automations", "automation", "auto"):
|
|
1341
|
+
args_a = (sc.args or "").strip()
|
|
1342
|
+
parts = args_a.split() if args_a else []
|
|
1343
|
+
sub = (parts[0].strip().lower() if parts else "status")
|
|
1344
|
+
a_dir = cfg.project_root / ".gemcode" / "automations"
|
|
1345
|
+
a_state = a_dir / "state.json"
|
|
1346
|
+
|
|
1347
|
+
def _bool_env(name: str) -> bool:
|
|
1348
|
+
return os.environ.get(name, "0").strip().lower() in ("1", "true", "yes", "on")
|
|
1349
|
+
|
|
1350
|
+
if sub in ("help", "?"):
|
|
1351
|
+
out("Usage:")
|
|
1352
|
+
out(" /automations Status (enabled, count, state file)")
|
|
1353
|
+
out(" /automations list List .gemcode/automations/*.json")
|
|
1354
|
+
out(" /automations on|off Enable/disable local scheduling (sets GEMCODE_AUTOMATIONS)")
|
|
1355
|
+
out(" /automations init <name> Create a starter automation json")
|
|
1356
|
+
out(" /automations run <name> Enqueue an automation now via Kaira IPC (if running)")
|
|
1357
|
+
out(" /automations heartbeat off")
|
|
1358
|
+
out(" /automations heartbeat <seconds> [prompt...] Set heartbeat interval + optional prompt")
|
|
1359
|
+
out()
|
|
1360
|
+
out("Paths:")
|
|
1361
|
+
out(f" dir : {a_dir}")
|
|
1362
|
+
out(f" state: {a_state}")
|
|
1363
|
+
out()
|
|
1364
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1365
|
+
|
|
1366
|
+
if sub in ("on", "enable", "enabled"):
|
|
1367
|
+
os.environ["GEMCODE_AUTOMATIONS"] = "1"
|
|
1368
|
+
out("automations: on (GEMCODE_AUTOMATIONS=1)")
|
|
1369
|
+
out("Note: requires a running Kaira daemon (external or embedded) to execute.")
|
|
1370
|
+
out()
|
|
1371
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1372
|
+
if sub in ("off", "disable", "disabled"):
|
|
1373
|
+
os.environ["GEMCODE_AUTOMATIONS"] = "0"
|
|
1374
|
+
out("automations: off (GEMCODE_AUTOMATIONS=0)")
|
|
1375
|
+
out()
|
|
1376
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1377
|
+
|
|
1378
|
+
if sub == "heartbeat":
|
|
1379
|
+
if len(parts) >= 2 and parts[1].strip().lower() in ("off", "disable", "clear", "0"):
|
|
1380
|
+
os.environ["GEMCODE_KAIRA_HEARTBEAT_EVERY_S"] = "0"
|
|
1381
|
+
os.environ.pop("GEMCODE_KAIRA_HEARTBEAT_PROMPT", None)
|
|
1382
|
+
out("heartbeat: off")
|
|
1383
|
+
out()
|
|
1384
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1385
|
+
if len(parts) < 2:
|
|
1386
|
+
cur = int(os.environ.get("GEMCODE_KAIRA_HEARTBEAT_EVERY_S", "0") or "0")
|
|
1387
|
+
pr = os.environ.get("GEMCODE_KAIRA_HEARTBEAT_PROMPT", "") or ""
|
|
1388
|
+
out(f"heartbeat_every_s: {cur}")
|
|
1389
|
+
if pr:
|
|
1390
|
+
out(f"heartbeat_prompt: {pr}")
|
|
1391
|
+
out()
|
|
1392
|
+
out("Set: /automations heartbeat 240 Heartbeat: summarise running jobs")
|
|
1393
|
+
out("Off: /automations heartbeat off")
|
|
1394
|
+
out()
|
|
1395
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1396
|
+
try:
|
|
1397
|
+
seconds = int(parts[1])
|
|
1398
|
+
except ValueError:
|
|
1399
|
+
seconds = 0
|
|
1400
|
+
if seconds <= 0:
|
|
1401
|
+
out("heartbeat: invalid seconds (use integer > 0)")
|
|
1402
|
+
out()
|
|
1403
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1404
|
+
os.environ["GEMCODE_AUTOMATIONS"] = "1"
|
|
1405
|
+
os.environ["GEMCODE_KAIRA_HEARTBEAT_EVERY_S"] = str(seconds)
|
|
1406
|
+
rest = args_a.split(None, 2)
|
|
1407
|
+
if len(rest) >= 3 and rest[2].strip():
|
|
1408
|
+
os.environ["GEMCODE_KAIRA_HEARTBEAT_PROMPT"] = rest[2].strip()
|
|
1409
|
+
out(f"heartbeat: on (every {seconds}s)")
|
|
1410
|
+
out()
|
|
1411
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1412
|
+
|
|
1413
|
+
if sub in ("init", "new") and len(parts) >= 2:
|
|
1414
|
+
name_raw = parts[1].strip().lower()
|
|
1415
|
+
import re
|
|
1416
|
+
|
|
1417
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-_]{0,63}", name_raw):
|
|
1418
|
+
out("Invalid name. Use lowercase letters/numbers plus - or _ (max 64 chars).")
|
|
1419
|
+
out()
|
|
1420
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1421
|
+
a_dir.mkdir(parents=True, exist_ok=True)
|
|
1422
|
+
p = a_dir / f"{name_raw}.json"
|
|
1423
|
+
if p.exists():
|
|
1424
|
+
out(f"Already exists: {p}")
|
|
1425
|
+
out()
|
|
1426
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1427
|
+
template = {
|
|
1428
|
+
"name": name_raw,
|
|
1429
|
+
"enabled": True,
|
|
1430
|
+
"priority": 0,
|
|
1431
|
+
"prompt": "Describe exactly what to do and what success looks like.",
|
|
1432
|
+
"triggers": [{"kind": "nightly", "at": "02:00"}],
|
|
1433
|
+
}
|
|
1434
|
+
try:
|
|
1435
|
+
import json
|
|
1436
|
+
|
|
1437
|
+
p.write_text(json.dumps(template, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1438
|
+
except Exception as e:
|
|
1439
|
+
out(f"Failed to write: {e}")
|
|
1440
|
+
out()
|
|
1441
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1442
|
+
out(f"Created: {p}")
|
|
1443
|
+
out("Enable runner-side execution with: gemcode kaira --automations (or GEMCODE_AUTOMATIONS=1)")
|
|
1444
|
+
out()
|
|
1445
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1446
|
+
|
|
1447
|
+
if sub in ("run",) and len(parts) >= 2:
|
|
1448
|
+
target = parts[1].strip().lower()
|
|
1449
|
+
cfgs = {}
|
|
1450
|
+
try:
|
|
1451
|
+
from gemcode.automations import load_automations
|
|
1452
|
+
|
|
1453
|
+
for a in load_automations(cfg.project_root):
|
|
1454
|
+
cfgs[a.name.lower()] = a
|
|
1455
|
+
except Exception:
|
|
1456
|
+
cfgs = {}
|
|
1457
|
+
a = cfgs.get(target)
|
|
1458
|
+
if a is None:
|
|
1459
|
+
out(f"Unknown automation: {target}")
|
|
1460
|
+
out("Tip: /automations list")
|
|
1461
|
+
out()
|
|
1462
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1463
|
+
# Enqueue via Kaira IPC.
|
|
1464
|
+
sock = os.environ.get("GEMCODE_KAIRA_SOCKET") or str(cfg.project_root / ".gemcode" / "ipc.sock")
|
|
1465
|
+
try:
|
|
1466
|
+
from gemcode.kaira_client import KairaIpcClient
|
|
1467
|
+
|
|
1468
|
+
client = await KairaIpcClient.connect(socket_path=sock)
|
|
1469
|
+
try:
|
|
1470
|
+
res = await client.request(action="enqueue", prompt=a.prompt, priority=a.priority, session_id=(a.session_id or session_id))
|
|
1471
|
+
finally:
|
|
1472
|
+
await client.close()
|
|
1473
|
+
if not res.get("ok"):
|
|
1474
|
+
out(f"[kaira] {res.get('error') or 'enqueue failed'}")
|
|
1475
|
+
else:
|
|
1476
|
+
out(f"[kaira] enqueued: {res.get('job_id')}")
|
|
1477
|
+
out()
|
|
1478
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1479
|
+
except Exception as e:
|
|
1480
|
+
out(f"[kaira] IPC unavailable: {type(e).__name__}: {e}")
|
|
1481
|
+
out("Start Kaira with: gemcode kaira -C . --automations")
|
|
1482
|
+
out()
|
|
1483
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1484
|
+
|
|
1485
|
+
if sub in ("list", "ls", "show"):
|
|
1486
|
+
try:
|
|
1487
|
+
from gemcode.automations import load_automations
|
|
1488
|
+
|
|
1489
|
+
autos = load_automations(cfg.project_root)
|
|
1490
|
+
except Exception:
|
|
1491
|
+
autos = []
|
|
1492
|
+
out(f"automations_enabled: {_bool_env('GEMCODE_AUTOMATIONS')}")
|
|
1493
|
+
out(f"dir: {a_dir} ({'exists' if a_dir.is_dir() else 'missing'})")
|
|
1494
|
+
out(f"state: {a_state} ({'exists' if a_state.is_file() else 'missing'})")
|
|
1495
|
+
if not autos:
|
|
1496
|
+
out("(no automation configs found)")
|
|
1497
|
+
out()
|
|
1498
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1499
|
+
out("Configs:")
|
|
1500
|
+
for a in autos[:200]:
|
|
1501
|
+
trig = ", ".join(t.key() for t in a.triggers) if a.triggers else "(no triggers)"
|
|
1502
|
+
out(f" - {a.name}\tenabled={a.enabled}\tpriority={a.priority}\t{trig}")
|
|
1503
|
+
if len(autos) > 200:
|
|
1504
|
+
out(f" … (+{len(autos) - 200} more)")
|
|
1505
|
+
out()
|
|
1506
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1507
|
+
|
|
1508
|
+
# status default
|
|
1509
|
+
try:
|
|
1510
|
+
from gemcode.automations import load_automations
|
|
1511
|
+
|
|
1512
|
+
autos2 = load_automations(cfg.project_root)
|
|
1513
|
+
except Exception:
|
|
1514
|
+
autos2 = []
|
|
1515
|
+
out(f"automations_enabled: {_bool_env('GEMCODE_AUTOMATIONS')}")
|
|
1516
|
+
out(f"configs: {len(autos2)} (dir: {a_dir})")
|
|
1517
|
+
out(f"state_file: {a_state} ({'exists' if a_state.is_file() else 'missing'})")
|
|
1518
|
+
out()
|
|
1519
|
+
out("Tip: /automations list · Enable: gemcode kaira --automations · Help: /automations help")
|
|
1520
|
+
out()
|
|
1521
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1522
|
+
|
|
1523
|
+
# ── /afc (Automatic Function Calling UX) ───────────────────────────────────
|
|
1524
|
+
if name == "afc":
|
|
1525
|
+
args_f = (sc.args or "").strip()
|
|
1526
|
+
parts = args_f.split() if args_f else []
|
|
1527
|
+
sub = (parts[0].strip().lower() if parts else "status")
|
|
1528
|
+
|
|
1529
|
+
def _norm(v: str) -> str:
|
|
1530
|
+
return (v or "").strip().lower()
|
|
1531
|
+
|
|
1532
|
+
if sub in ("help", "?"):
|
|
1533
|
+
out("Usage:")
|
|
1534
|
+
out(" /afc Show AFC prompt settings")
|
|
1535
|
+
out(" /afc default all|callables|clear Set GEMCODE_AFC_DEFAULT")
|
|
1536
|
+
out(" /afc prompt on|off Set GEMCODE_AFC_PROMPT")
|
|
1537
|
+
out()
|
|
1538
|
+
out("Notes:")
|
|
1539
|
+
out(" These affect runner construction; GemCode will rebuild runner on next turn.")
|
|
1540
|
+
out()
|
|
1541
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1542
|
+
|
|
1543
|
+
if sub == "default":
|
|
1544
|
+
if len(parts) < 2:
|
|
1545
|
+
out(f"GEMCODE_AFC_DEFAULT: {os.environ.get('GEMCODE_AFC_DEFAULT', '(unset)')}")
|
|
1546
|
+
out()
|
|
1547
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1548
|
+
v = _norm(parts[1])
|
|
1549
|
+
if v in ("clear", "unset", "off", "none"):
|
|
1550
|
+
os.environ.pop("GEMCODE_AFC_DEFAULT", None)
|
|
1551
|
+
out("GEMCODE_AFC_DEFAULT: (unset)")
|
|
1552
|
+
out("Runner will rebuild on next turn.")
|
|
1553
|
+
out()
|
|
1554
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
1555
|
+
if v not in ("all", "callables"):
|
|
1556
|
+
out("Invalid. Use: all|callables|clear")
|
|
1557
|
+
out()
|
|
1558
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1559
|
+
os.environ["GEMCODE_AFC_DEFAULT"] = v
|
|
1560
|
+
out(f"GEMCODE_AFC_DEFAULT: {v}")
|
|
1561
|
+
out("Runner will rebuild on next turn.")
|
|
1562
|
+
out()
|
|
1563
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
1564
|
+
|
|
1565
|
+
if sub == "prompt":
|
|
1566
|
+
if len(parts) < 2:
|
|
1567
|
+
out(f"GEMCODE_AFC_PROMPT: {os.environ.get('GEMCODE_AFC_PROMPT', '(unset => default on)')}")
|
|
1568
|
+
out()
|
|
1569
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1570
|
+
v2 = _norm(parts[1])
|
|
1571
|
+
if v2 in ("on", "1", "true", "yes"):
|
|
1572
|
+
os.environ["GEMCODE_AFC_PROMPT"] = "1"
|
|
1573
|
+
elif v2 in ("off", "0", "false", "no"):
|
|
1574
|
+
os.environ["GEMCODE_AFC_PROMPT"] = "0"
|
|
1575
|
+
else:
|
|
1576
|
+
out("Invalid. Use: on|off")
|
|
1577
|
+
out()
|
|
1578
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1579
|
+
out(f"GEMCODE_AFC_PROMPT: {os.environ.get('GEMCODE_AFC_PROMPT')}")
|
|
1580
|
+
out("Runner will rebuild on next turn.")
|
|
1581
|
+
out()
|
|
1582
|
+
return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
|
|
1583
|
+
|
|
1584
|
+
out("AFC:")
|
|
1585
|
+
out(f" GEMCODE_AFC_PROMPT : {os.environ.get('GEMCODE_AFC_PROMPT', '(unset => default on)')}")
|
|
1586
|
+
out(f" GEMCODE_AFC_DEFAULT: {os.environ.get('GEMCODE_AFC_DEFAULT', '(unset)')}")
|
|
1587
|
+
out()
|
|
1588
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
1589
|
+
|
|
1339
1590
|
if name == "tools":
|
|
1340
1591
|
args_t = (sc.args or "").strip().lower()
|
|
1341
1592
|
if args_t in ("smoke", "decl", "declarations"):
|
|
@@ -7,6 +7,7 @@ src/gemcode/__main__.py
|
|
|
7
7
|
src/gemcode/agent.py
|
|
8
8
|
src/gemcode/audit.py
|
|
9
9
|
src/gemcode/autocompact.py
|
|
10
|
+
src/gemcode/automations.py
|
|
10
11
|
src/gemcode/autotune.py
|
|
11
12
|
src/gemcode/callbacks.py
|
|
12
13
|
src/gemcode/capability_routing.py
|
|
@@ -126,6 +127,7 @@ src/gemcode/web/web_sse_compat.py
|
|
|
126
127
|
tests/test_add_dir.py
|
|
127
128
|
tests/test_agent_instruction.py
|
|
128
129
|
tests/test_autocompact.py
|
|
130
|
+
tests/test_automations.py
|
|
129
131
|
tests/test_capability_routing.py
|
|
130
132
|
tests/test_checkpoint_diff_command.py
|
|
131
133
|
tests/test_cli_init.py
|