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