gemcode 0.3.109__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.109/src/gemcode.egg-info → gemcode-0.3.111}/PKG-INFO +1 -1
- {gemcode-0.3.109 → gemcode-0.3.111}/pyproject.toml +1 -1
- gemcode-0.3.111/src/gemcode/automations.py +198 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/cli.py +27 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/kaira_daemon.py +124 -5
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/repl_commands.py +4 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/repl_slash.py +251 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/session_runtime.py +3 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tui/scrollback.py +56 -0
- {gemcode-0.3.109 → gemcode-0.3.111/src/gemcode.egg-info}/PKG-INFO +1 -1
- {gemcode-0.3.109 → 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.109 → gemcode-0.3.111}/LICENSE +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/MANIFEST.in +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/README.md +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/setup.cfg +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/__main__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/agent.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/audit.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/autotune.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/compaction.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/config.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/credentials.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/hooks.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/interactions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/invoke.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/learning.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/limits.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/org.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/paths.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/permissions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/pricing.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/config.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/refine.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/rules.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/session_store.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/skills.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/thinking.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/trust.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/version.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/vertex.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/wal.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/web/sse_adapter.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_add_dir.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_autocompact.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_capability_routing.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_cli_init.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_context_budget.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_context_warning.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_credentials.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_modality_tools.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_model_errors.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_model_routing.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_paths.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_permissions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_repl_commands.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_repl_slash.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_skills.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_slash_commands.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_thinking_config.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_token_budget.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_tools.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.3.109 → gemcode-0.3.111}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.3.109 → 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
|
|
@@ -94,6 +95,22 @@ def _fmt_tool_result(resp: object) -> str:
|
|
|
94
95
|
return ""
|
|
95
96
|
|
|
96
97
|
|
|
98
|
+
def _should_stream_to_terminal() -> bool:
|
|
99
|
+
"""Stream live job output to the local terminal when interactive."""
|
|
100
|
+
try:
|
|
101
|
+
return bool(hasattr(sys.stdin, "isatty") and sys.stdin.isatty())
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _stream_print(s: str) -> None:
|
|
107
|
+
try:
|
|
108
|
+
sys.stdout.write(s)
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
97
114
|
async def _broadcast_text_delta(
|
|
98
115
|
*,
|
|
99
116
|
ipc: KairaIpcServer,
|
|
@@ -206,6 +223,65 @@ class KairaDaemon:
|
|
|
206
223
|
# We'll implement dynamic resizing once the rest of the control plane is stable.
|
|
207
224
|
return int(self.concurrency)
|
|
208
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
|
+
|
|
209
285
|
def enqueue_prompt(
|
|
210
286
|
self,
|
|
211
287
|
*,
|
|
@@ -402,6 +478,9 @@ class KairaDaemon:
|
|
|
402
478
|
async def _stream_one_message(*, current_message: types.Content) -> tuple[list, str]:
|
|
403
479
|
emitted_text = ""
|
|
404
480
|
events: list = []
|
|
481
|
+
stream_live = _should_stream_to_terminal()
|
|
482
|
+
if stream_live:
|
|
483
|
+
_stream_print(f"\n[kaira {job.job_id}] started\n")
|
|
405
484
|
async for ev in runner.run_async(
|
|
406
485
|
user_id=self.user_id,
|
|
407
486
|
session_id=job.session_id,
|
|
@@ -409,6 +488,28 @@ class KairaDaemon:
|
|
|
409
488
|
**({"run_config": run_config} if run_config is not None else {}),
|
|
410
489
|
):
|
|
411
490
|
events.append(ev)
|
|
491
|
+
# Live terminal streaming (independent of IPC).
|
|
492
|
+
if stream_live:
|
|
493
|
+
try:
|
|
494
|
+
from gemcode.web.sse_adapter import extract_text_from_event
|
|
495
|
+
|
|
496
|
+
txt_live = extract_text_from_event(ev)
|
|
497
|
+
if txt_live:
|
|
498
|
+
if txt_live.startswith(emitted_text):
|
|
499
|
+
delta_live = txt_live[len(emitted_text) :]
|
|
500
|
+
else:
|
|
501
|
+
# Fallback: find common prefix.
|
|
502
|
+
common = 0
|
|
503
|
+
max_common = min(len(txt_live), len(emitted_text))
|
|
504
|
+
while common < max_common and txt_live[common] == emitted_text[common]:
|
|
505
|
+
common += 1
|
|
506
|
+
delta_live = txt_live[common:]
|
|
507
|
+
if delta_live:
|
|
508
|
+
_stream_print(delta_live)
|
|
509
|
+
emitted_text = txt_live
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
412
513
|
if self._ipc is None:
|
|
413
514
|
continue
|
|
414
515
|
|
|
@@ -460,7 +561,7 @@ class KairaDaemon:
|
|
|
460
561
|
except Exception:
|
|
461
562
|
pass
|
|
462
563
|
|
|
463
|
-
# Text deltas
|
|
564
|
+
# Text deltas (IPC subscribers)
|
|
464
565
|
try:
|
|
465
566
|
from gemcode.web.sse_adapter import extract_text_from_event
|
|
466
567
|
|
|
@@ -602,8 +703,12 @@ class KairaDaemon:
|
|
|
602
703
|
session_id=session_id,
|
|
603
704
|
)
|
|
604
705
|
|
|
605
|
-
async def run_forever(self, *, session_id: str) -> None:
|
|
606
|
-
"""Start the scheduler and keep running until
|
|
706
|
+
async def run_forever(self, *, session_id: str, enable_stdin: bool = True) -> None:
|
|
707
|
+
"""Start the scheduler and keep running until stopped.
|
|
708
|
+
|
|
709
|
+
When enable_stdin=False, Kaira runs headless (IPC-only) and does not read
|
|
710
|
+
from stdin. This mode is used when embedding Kaira inside the GemCode TUI.
|
|
711
|
+
"""
|
|
607
712
|
|
|
608
713
|
# Start IPC server for two-way control + event streaming.
|
|
609
714
|
try:
|
|
@@ -622,12 +727,26 @@ class KairaDaemon:
|
|
|
622
727
|
self._ipc = None
|
|
623
728
|
print(f"[kaira] ipc disabled: {e}", file=sys.stderr, flush=True)
|
|
624
729
|
|
|
730
|
+
import os as _os
|
|
731
|
+
|
|
625
732
|
scheduler_task = asyncio.create_task(self._scheduler_loop())
|
|
626
|
-
|
|
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
|
+
)
|
|
740
|
+
stdin_task = None
|
|
741
|
+
if enable_stdin:
|
|
742
|
+
stdin_task = asyncio.create_task(self._stdin_loop(session_id=session_id))
|
|
627
743
|
|
|
628
744
|
# Wait for either scheduler to stop (shouldn't happen) or stdin loop to end.
|
|
745
|
+
wait_set = {scheduler_task, automations_task}
|
|
746
|
+
if stdin_task is not None:
|
|
747
|
+
wait_set.add(stdin_task)
|
|
629
748
|
done, pending = await asyncio.wait(
|
|
630
|
-
|
|
749
|
+
wait_set,
|
|
631
750
|
return_when=asyncio.FIRST_COMPLETED,
|
|
632
751
|
)
|
|
633
752
|
for p in pending:
|
|
@@ -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",
|