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.
Files changed (166) hide show
  1. {gemcode-0.3.110/src/gemcode.egg-info → gemcode-0.3.111}/PKG-INFO +1 -1
  2. {gemcode-0.3.110 → gemcode-0.3.111}/pyproject.toml +1 -1
  3. gemcode-0.3.111/src/gemcode/automations.py +198 -0
  4. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/cli.py +27 -0
  5. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_daemon.py +70 -1
  6. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/repl_commands.py +4 -0
  7. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/repl_slash.py +251 -0
  8. {gemcode-0.3.110 → gemcode-0.3.111/src/gemcode.egg-info}/PKG-INFO +1 -1
  9. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/SOURCES.txt +2 -0
  10. gemcode-0.3.111/tests/test_automations.py +43 -0
  11. {gemcode-0.3.110 → gemcode-0.3.111}/LICENSE +0 -0
  12. {gemcode-0.3.110 → gemcode-0.3.111}/MANIFEST.in +0 -0
  13. {gemcode-0.3.110 → gemcode-0.3.111}/README.md +0 -0
  14. {gemcode-0.3.110 → gemcode-0.3.111}/setup.cfg +0 -0
  15. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/__init__.py +0 -0
  16. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/__main__.py +0 -0
  17. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/agent.py +0 -0
  18. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/audit.py +0 -0
  19. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/autocompact.py +0 -0
  20. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/autotune.py +0 -0
  21. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/callbacks.py +0 -0
  22. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/capability_routing.py +0 -0
  23. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/checkpoints.py +0 -0
  24. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/compaction.py +0 -0
  25. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/computer_use/__init__.py +0 -0
  26. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/computer_use/browser_computer.py +0 -0
  27. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/config.py +0 -0
  28. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/context_budget.py +0 -0
  29. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/context_warning.py +0 -0
  30. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/credentials.py +0 -0
  31. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/curated_memory.py +0 -0
  32. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/dynamic_policy.py +0 -0
  33. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/evals/harness.py +0 -0
  34. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/hitl_session.py +0 -0
  35. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/hooks.py +0 -0
  36. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/ide_protocol.py +0 -0
  37. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/ide_stdio.py +0 -0
  38. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/intent_classifier.py +0 -0
  39. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/interactions.py +0 -0
  40. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/invoke.py +0 -0
  41. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_client.py +0 -0
  42. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_ipc.py +0 -0
  43. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/kaira_job_store.py +0 -0
  44. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/learning.py +0 -0
  45. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/limits.py +0 -0
  46. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/live_audio_engine.py +0 -0
  47. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/logging_config.py +0 -0
  48. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/mcp_loader.py +0 -0
  49. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/__init__.py +0 -0
  50. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/embedding_memory_service.py +0 -0
  51. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/memory/file_memory_service.py +0 -0
  52. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/modality_tools.py +0 -0
  53. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/model_errors.py +0 -0
  54. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/model_routing.py +0 -0
  55. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/multimodal_input.py +0 -0
  56. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/openapi_loader.py +0 -0
  57. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/org.py +0 -0
  58. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/output_styles.py +0 -0
  59. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/paths.py +0 -0
  60. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/permissions.py +0 -0
  61. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/__init__.py +0 -0
  62. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  63. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  64. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/policy_profile.py +0 -0
  65. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/pricing.py +0 -0
  66. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/prompt_suggestions.py +0 -0
  67. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/__init__.py +0 -0
  68. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/config.py +0 -0
  69. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/deps.py +0 -0
  70. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/engine.py +0 -0
  71. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/stop_hooks.py +0 -0
  72. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/token_budget.py +0 -0
  73. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query/transitions.py +0 -0
  74. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/query_sanitizer.py +0 -0
  75. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/refine.py +0 -0
  76. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/review_agent.py +0 -0
  77. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/rules.py +0 -0
  78. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_runtime.py +0 -0
  79. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_store.py +0 -0
  80. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/session_summariser.py +0 -0
  81. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/skills.py +0 -0
  82. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/slash_commands.py +0 -0
  83. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/thinking.py +0 -0
  84. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_prompt_manifest.py +0 -0
  85. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_registry.py +0 -0
  86. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tool_result_store.py +0 -0
  87. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/__init__.py +0 -0
  88. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/bash.py +0 -0
  89. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/browser.py +0 -0
  90. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/compress_memory.py +0 -0
  91. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/curated_memory.py +0 -0
  92. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/edit.py +0 -0
  93. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/filesystem.py +0 -0
  94. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/notebook.py +0 -0
  95. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/notes.py +0 -0
  96. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/org_tools.py +0 -0
  97. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/repo_map.py +0 -0
  98. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/search.py +0 -0
  99. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/shell.py +0 -0
  100. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/shell_gate.py +0 -0
  101. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/skills.py +0 -0
  102. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/subtask.py +0 -0
  103. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/tasks.py +0 -0
  104. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/think.py +0 -0
  105. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/todo.py +0 -0
  106. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/user_choice.py +0 -0
  107. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/veomem_tools.py +0 -0
  108. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/web.py +0 -0
  109. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools/web_search.py +0 -0
  110. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tools_inspector.py +0 -0
  111. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/trust.py +0 -0
  112. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/input_handler.py +0 -0
  113. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/scrollback.py +0 -0
  114. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/spinner.py +0 -0
  115. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/welcome_banner.py +0 -0
  116. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/tui/welcome_rich.py +0 -0
  117. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/veomem_bridge.py +0 -0
  118. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/version.py +0 -0
  119. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/vertex.py +0 -0
  120. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/wal.py +0 -0
  121. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/__init__.py +0 -0
  122. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/sse_adapter.py +0 -0
  123. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/terminal_repl.py +0 -0
  124. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/web/web_sse_compat.py +0 -0
  125. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode/workspace_hints.py +0 -0
  126. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/dependency_links.txt +0 -0
  127. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/entry_points.txt +0 -0
  128. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/requires.txt +0 -0
  129. {gemcode-0.3.110 → gemcode-0.3.111}/src/gemcode.egg-info/top_level.txt +0 -0
  130. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_add_dir.py +0 -0
  131. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_agent_instruction.py +0 -0
  132. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_autocompact.py +0 -0
  133. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_capability_routing.py +0 -0
  134. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_checkpoint_diff_command.py +0 -0
  135. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_cli_init.py +0 -0
  136. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_compress_memory_tool.py +0 -0
  137. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_computer_use_permissions.py +0 -0
  138. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_context_budget.py +0 -0
  139. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_context_warning.py +0 -0
  140. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_credentials.py +0 -0
  141. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_eval_harness_layout.py +0 -0
  142. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_ide_stdio_attachments.py +0 -0
  143. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_interactive_permission_ask.py +0 -0
  144. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_kaira_scheduler.py +0 -0
  145. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_modality_tools.py +0 -0
  146. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_error_retry.py +0 -0
  147. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_errors.py +0 -0
  148. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_model_routing.py +0 -0
  149. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_multimodal_input.py +0 -0
  150. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_output_styles_and_rules.py +0 -0
  151. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_paths.py +0 -0
  152. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_permissions.py +0 -0
  153. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_prompt_suggestions.py +0 -0
  154. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_repl_commands.py +0 -0
  155. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_repl_slash.py +0 -0
  156. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_session_runtime_cache.py +0 -0
  157. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_skills.py +0 -0
  158. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_slash_commands.py +0 -0
  159. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_slash_completion_registry.py +0 -0
  160. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_thinking_config.py +0 -0
  161. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_token_budget.py +0 -0
  162. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tool_context_circulation.py +0 -0
  163. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tools.py +0 -0
  164. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_tools_inspector.py +0 -0
  165. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_web_sse_adapter.py +0 -0
  166. {gemcode-0.3.110 → gemcode-0.3.111}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.110
3
+ Version: 0.3.111
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.110"
7
+ version = "0.3.111"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.110
3
+ Version: 0.3.111
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -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