arkaos 2.9.0 → 2.10.0
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.
- package/VERSION +1 -1
- package/config/cognition/prompts/dreaming.md +208 -0
- package/config/cognition/prompts/research.md +194 -0
- package/config/cognition/schedules.yaml +25 -0
- package/core/cognition/__init__.py +7 -0
- package/core/cognition/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/capture/__init__.py +5 -0
- package/core/cognition/capture/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
- package/core/cognition/capture/collector.py +80 -0
- package/core/cognition/capture/store.py +158 -0
- package/core/cognition/insights/__init__.py +5 -0
- package/core/cognition/insights/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/insights/__pycache__/store.cpython-313.pyc +0 -0
- package/core/cognition/insights/store.py +155 -0
- package/core/cognition/memory/__init__.py +9 -0
- package/core/cognition/memory/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/obsidian.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/vector.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/cognition/memory/obsidian.py +73 -0
- package/core/cognition/memory/schemas.py +141 -0
- package/core/cognition/memory/vector.py +223 -0
- package/core/cognition/memory/writer.py +57 -0
- package/core/cognition/research/__init__.py +5 -0
- package/core/cognition/research/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/research/__pycache__/profiler.cpython-313.pyc +0 -0
- package/core/cognition/research/profiler.py +256 -0
- package/core/cognition/scheduler/__init__.py +5 -0
- package/core/cognition/scheduler/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/platform.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/cli.py +86 -0
- package/core/cognition/scheduler/daemon.py +172 -0
- package/core/cognition/scheduler/platform.py +292 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""ResearchProfiler — infers adaptive research topics from project ecosystems.
|
|
2
|
+
|
|
3
|
+
Reads ecosystems.json, extracts stacks and domains, and maps them to curated
|
|
4
|
+
research topics for automated content monitoring and trend awareness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --- Topic mapping dictionaries ---
|
|
15
|
+
|
|
16
|
+
STACK_TOPICS: dict[str, list[str]] = {
|
|
17
|
+
"laravel": [
|
|
18
|
+
"Laravel releases and security patches",
|
|
19
|
+
"Laravel ecosystem packages",
|
|
20
|
+
"PHP security advisories",
|
|
21
|
+
],
|
|
22
|
+
"nuxt": [
|
|
23
|
+
"Nuxt 3/4 releases and migration guides",
|
|
24
|
+
"Vue 3 ecosystem updates",
|
|
25
|
+
],
|
|
26
|
+
"vue": [
|
|
27
|
+
"Vue 3 composition API patterns",
|
|
28
|
+
"Vue ecosystem tooling updates",
|
|
29
|
+
],
|
|
30
|
+
"react": [
|
|
31
|
+
"React releases and concurrent features",
|
|
32
|
+
"Next.js app router updates",
|
|
33
|
+
"React ecosystem library news",
|
|
34
|
+
],
|
|
35
|
+
"python": [
|
|
36
|
+
"Python release notes and deprecations",
|
|
37
|
+
"FastAPI and async Python patterns",
|
|
38
|
+
"Python security CVEs",
|
|
39
|
+
],
|
|
40
|
+
"shopify-liquid": [
|
|
41
|
+
"Shopify platform updates and API changes",
|
|
42
|
+
"Shopify theme development best practices",
|
|
43
|
+
"Shopify app ecosystem news",
|
|
44
|
+
],
|
|
45
|
+
"ai": [
|
|
46
|
+
"Large language model releases and benchmarks",
|
|
47
|
+
"AI agent framework updates",
|
|
48
|
+
"Anthropic and OpenAI product announcements",
|
|
49
|
+
],
|
|
50
|
+
"energy": [
|
|
51
|
+
"Energy sector digital transformation trends",
|
|
52
|
+
"API governance in regulated industries",
|
|
53
|
+
"Enterprise integration patterns for utilities",
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
DOMAIN_TOPICS: dict[str, list[str]] = {
|
|
58
|
+
"ecommerce": [
|
|
59
|
+
"E-commerce conversion rate trends",
|
|
60
|
+
"Marketplace integration updates",
|
|
61
|
+
"Payment gateway changes and PSD2 compliance",
|
|
62
|
+
],
|
|
63
|
+
"media": [
|
|
64
|
+
"Streaming technology updates",
|
|
65
|
+
"Content delivery network trends",
|
|
66
|
+
"Viral content mechanics and audience growth",
|
|
67
|
+
],
|
|
68
|
+
"enterprise": [
|
|
69
|
+
"Enterprise architecture and API governance",
|
|
70
|
+
"System integration patterns",
|
|
71
|
+
"Cloud migration strategies",
|
|
72
|
+
],
|
|
73
|
+
"saas": [
|
|
74
|
+
"SaaS pricing model trends",
|
|
75
|
+
"Product-led growth tactics",
|
|
76
|
+
"Micro-SaaS opportunity scouting",
|
|
77
|
+
],
|
|
78
|
+
"news": [
|
|
79
|
+
"CMS architecture and headless content",
|
|
80
|
+
"SEO for news portals and Google News",
|
|
81
|
+
"Security hardening for content platforms",
|
|
82
|
+
],
|
|
83
|
+
"events": [
|
|
84
|
+
"Event platform technology stack trends",
|
|
85
|
+
"Ticketing and registration UX patterns",
|
|
86
|
+
"Landing page conversion for events",
|
|
87
|
+
],
|
|
88
|
+
"consulting": [
|
|
89
|
+
"Web application architecture trends",
|
|
90
|
+
"Client delivery workflow optimisation",
|
|
91
|
+
"TypeScript and Nuxt ecosystem updates",
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Keywords used to infer domain from ecosystem description and type
|
|
96
|
+
_DOMAIN_SIGNALS: dict[str, list[str]] = {
|
|
97
|
+
"ecommerce": ["e-commerce", "ecommerce", "marketplace", "shopify", " erp ", "supplier"],
|
|
98
|
+
"media": ["media", "content", "viral", "streaming", "news portal", "quiz", "audience"],
|
|
99
|
+
"enterprise": ["enterprise integration", "api governance", "energy utility", "soap ", "kafka"],
|
|
100
|
+
"saas": ["micro-saas", "saas", "ai tools", "growth engine", "revenue target"],
|
|
101
|
+
"news": ["news", "portal", "cms", "journalism"],
|
|
102
|
+
"events": ["event", "conference", "landing", "registration"],
|
|
103
|
+
"consulting": ["consulting", "services"],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ResearchTopic:
|
|
109
|
+
"""A single research topic derived from a stack or domain."""
|
|
110
|
+
|
|
111
|
+
name: str
|
|
112
|
+
source: str # "stack" | "domain" | "tool" | "business"
|
|
113
|
+
search_queries: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ResearchProfile:
|
|
118
|
+
"""Aggregated research profile for all active project ecosystems."""
|
|
119
|
+
|
|
120
|
+
stacks: list[str]
|
|
121
|
+
domains: list[str]
|
|
122
|
+
tools: list[str]
|
|
123
|
+
business_interests: list[str]
|
|
124
|
+
competitors: list[str]
|
|
125
|
+
topics: list[ResearchTopic]
|
|
126
|
+
|
|
127
|
+
def to_yaml(self) -> str:
|
|
128
|
+
"""Serialise the profile to a YAML string."""
|
|
129
|
+
data = {
|
|
130
|
+
"stacks": self.stacks,
|
|
131
|
+
"domains": self.domains,
|
|
132
|
+
"tools": self.tools,
|
|
133
|
+
"business_interests": self.business_interests,
|
|
134
|
+
"competitors": self.competitors,
|
|
135
|
+
"topics": [
|
|
136
|
+
{
|
|
137
|
+
"name": t.name,
|
|
138
|
+
"source": t.source,
|
|
139
|
+
"search_queries": t.search_queries,
|
|
140
|
+
}
|
|
141
|
+
for t in self.topics
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
return yaml.dump(data, default_flow_style=False, allow_unicode=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalise_stack_name(raw: str) -> str:
|
|
148
|
+
"""Return a canonical stack key from a raw tech string."""
|
|
149
|
+
lower = raw.lower().strip()
|
|
150
|
+
if lower.startswith("laravel"):
|
|
151
|
+
return "laravel"
|
|
152
|
+
if lower.startswith("nuxt"):
|
|
153
|
+
return "nuxt"
|
|
154
|
+
if lower.startswith("vue"):
|
|
155
|
+
return "vue"
|
|
156
|
+
if lower.startswith("react") or lower.startswith("next"):
|
|
157
|
+
return "react"
|
|
158
|
+
if lower.startswith("python") or lower.startswith("fastapi") or lower.startswith("pydantic"):
|
|
159
|
+
return "python"
|
|
160
|
+
if "shopify" in lower:
|
|
161
|
+
return "shopify-liquid"
|
|
162
|
+
if "openai" in lower or "anthropic" in lower or "claude" in lower or "gpt" in lower:
|
|
163
|
+
return "ai"
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_stacks_from_ecosystem(eco: dict) -> list[str]:
|
|
168
|
+
"""Extract canonical stack names from an ecosystem's tech_stack dict."""
|
|
169
|
+
tech_stack = eco.get("tech_stack", {})
|
|
170
|
+
found: set[str] = set()
|
|
171
|
+
for _category, entries in tech_stack.items():
|
|
172
|
+
if not isinstance(entries, list):
|
|
173
|
+
continue
|
|
174
|
+
for entry in entries:
|
|
175
|
+
key = _normalise_stack_name(str(entry))
|
|
176
|
+
if key:
|
|
177
|
+
found.add(key)
|
|
178
|
+
return list(found)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _infer_domain(eco: dict) -> str:
|
|
182
|
+
"""Infer a domain label from ecosystem description, capabilities, and type."""
|
|
183
|
+
text = " ".join([
|
|
184
|
+
eco.get("description", ""),
|
|
185
|
+
eco.get("name", ""),
|
|
186
|
+
str(eco.get("capabilities", "")),
|
|
187
|
+
]).lower()
|
|
188
|
+
|
|
189
|
+
for domain, signals in _DOMAIN_SIGNALS.items():
|
|
190
|
+
if any(sig in text for sig in signals):
|
|
191
|
+
return domain
|
|
192
|
+
return "consulting"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ResearchProfiler:
|
|
196
|
+
"""Builds a ResearchProfile by inspecting the ArkaOS ecosystems registry."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, ecosystems_path: str) -> None:
|
|
199
|
+
"""Load the ecosystems registry from disk."""
|
|
200
|
+
self._path = Path(ecosystems_path)
|
|
201
|
+
|
|
202
|
+
def build_profile(self) -> ResearchProfile:
|
|
203
|
+
"""Parse ecosystems.json and produce a unified ResearchProfile."""
|
|
204
|
+
raw = self._load()
|
|
205
|
+
ecosystems = raw.get("ecosystems", {})
|
|
206
|
+
|
|
207
|
+
stacks: set[str] = set()
|
|
208
|
+
domains: set[str] = set()
|
|
209
|
+
|
|
210
|
+
for eco in ecosystems.values():
|
|
211
|
+
stacks.update(_extract_stacks_from_ecosystem(eco))
|
|
212
|
+
domains.add(_infer_domain(eco))
|
|
213
|
+
|
|
214
|
+
topics = self._generate_topics(stacks, domains)
|
|
215
|
+
|
|
216
|
+
return ResearchProfile(
|
|
217
|
+
stacks=sorted(stacks),
|
|
218
|
+
domains=sorted(domains),
|
|
219
|
+
tools=[],
|
|
220
|
+
business_interests=[],
|
|
221
|
+
competitors=[],
|
|
222
|
+
topics=topics,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _load(self) -> dict:
|
|
226
|
+
"""Read and parse the ecosystems JSON file."""
|
|
227
|
+
if not self._path.exists():
|
|
228
|
+
return {"ecosystems": {}}
|
|
229
|
+
with self._path.open(encoding="utf-8") as fh:
|
|
230
|
+
return json.load(fh)
|
|
231
|
+
|
|
232
|
+
def _generate_topics(
|
|
233
|
+
self,
|
|
234
|
+
stacks: set[str],
|
|
235
|
+
domains: set[str],
|
|
236
|
+
) -> list[ResearchTopic]:
|
|
237
|
+
"""Generate ResearchTopic instances from stack and domain mappings."""
|
|
238
|
+
topics: list[ResearchTopic] = []
|
|
239
|
+
|
|
240
|
+
for stack in sorted(stacks):
|
|
241
|
+
if stack in STACK_TOPICS:
|
|
242
|
+
topics.append(ResearchTopic(
|
|
243
|
+
name=f"{stack} ecosystem",
|
|
244
|
+
source="stack",
|
|
245
|
+
search_queries=STACK_TOPICS[stack],
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
for domain in sorted(domains):
|
|
249
|
+
if domain in DOMAIN_TOPICS:
|
|
250
|
+
topics.append(ResearchTopic(
|
|
251
|
+
name=f"{domain} trends",
|
|
252
|
+
source="domain",
|
|
253
|
+
search_queries=DOMAIN_TOPICS[domain],
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
return topics
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""CLI functions for managing the ArkaOS cognitive scheduler."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from core.cognition.scheduler.daemon import ArkaScheduler, ScheduleConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def list_schedules(config_path: str) -> list[dict]:
|
|
9
|
+
"""Return a list of dicts with command, time, timeout, retry for each schedule."""
|
|
10
|
+
schedules = ScheduleConfig.load(config_path)
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
"command": s.command,
|
|
14
|
+
"time": s.run_time.strftime("%H:%M"),
|
|
15
|
+
"timeout": s.timeout_minutes,
|
|
16
|
+
"retry": s.retry_on_fail,
|
|
17
|
+
}
|
|
18
|
+
for s in schedules
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _last_run_date(log_dir: str, command: str) -> str:
|
|
23
|
+
"""Return the most recent log date for a command, or 'never'."""
|
|
24
|
+
command_log_dir = Path(log_dir) / command
|
|
25
|
+
if not command_log_dir.exists():
|
|
26
|
+
return "never"
|
|
27
|
+
|
|
28
|
+
log_files = sorted(command_log_dir.glob("*.log"), reverse=True)
|
|
29
|
+
if not log_files:
|
|
30
|
+
return "never"
|
|
31
|
+
|
|
32
|
+
return log_files[0].stem
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scheduler_status(config_path: str, log_dir: str, lock_path: str) -> str:
|
|
36
|
+
"""Return a formatted status string with schedule info and last runs."""
|
|
37
|
+
is_running = Path(lock_path).exists()
|
|
38
|
+
status_label = "RUNNING" if is_running else "STOPPED"
|
|
39
|
+
|
|
40
|
+
schedules = ScheduleConfig.load(config_path)
|
|
41
|
+
|
|
42
|
+
schedule_lines = []
|
|
43
|
+
for s in schedules:
|
|
44
|
+
time_str = s.run_time.strftime("%H:%M")
|
|
45
|
+
retry_str = ", retry" if s.retry_on_fail else ""
|
|
46
|
+
schedule_lines.append(
|
|
47
|
+
f" {s.command:<12} at {time_str} (timeout: {s.timeout_minutes}m{retry_str})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
last_run_lines = []
|
|
51
|
+
for s in schedules:
|
|
52
|
+
last_date = _last_run_date(log_dir, s.command)
|
|
53
|
+
last_run_lines.append(f" {s.command:<12} last: {last_date}")
|
|
54
|
+
|
|
55
|
+
lines = [
|
|
56
|
+
"=== ArkaOS Scheduler Status ===",
|
|
57
|
+
"",
|
|
58
|
+
f"Status: {status_label}",
|
|
59
|
+
"",
|
|
60
|
+
"Schedules:",
|
|
61
|
+
*schedule_lines,
|
|
62
|
+
"",
|
|
63
|
+
"Last runs:",
|
|
64
|
+
*last_run_lines,
|
|
65
|
+
"",
|
|
66
|
+
"===============================",
|
|
67
|
+
]
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_now(command: str, config_path: str, log_dir: str, lock_path: str) -> bool:
|
|
72
|
+
"""Execute a specific schedule immediately by command name.
|
|
73
|
+
|
|
74
|
+
Raises ValueError if the command is not found in the config.
|
|
75
|
+
"""
|
|
76
|
+
scheduler = ArkaScheduler(
|
|
77
|
+
config_path=config_path,
|
|
78
|
+
log_dir=log_dir,
|
|
79
|
+
lock_path=lock_path,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
match = next((s for s in scheduler.schedules if s.command == command), None)
|
|
83
|
+
if match is None:
|
|
84
|
+
raise ValueError(f"Unknown schedule command: {command!r}")
|
|
85
|
+
|
|
86
|
+
return scheduler.execute(match)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""ArkaScheduler — cross-platform daemon for running cognitive tasks on schedule.
|
|
2
|
+
|
|
3
|
+
Reads a YAML schedule config, acquires a file lock to prevent duplicate runs,
|
|
4
|
+
and executes Claude CLI commands with logging per task.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ScheduleConfig:
|
|
20
|
+
"""Configuration for a single scheduled cognitive task."""
|
|
21
|
+
|
|
22
|
+
command: str
|
|
23
|
+
prompt_file: str
|
|
24
|
+
run_time: time
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
retry_on_fail: bool = True
|
|
27
|
+
max_retries: int = 2
|
|
28
|
+
timeout_minutes: int = 60
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def load(cls, config_path: str) -> "list[ScheduleConfig]":
|
|
32
|
+
"""Load schedules from YAML, returning only enabled entries."""
|
|
33
|
+
with open(config_path) as fh:
|
|
34
|
+
data = yaml.safe_load(fh)
|
|
35
|
+
|
|
36
|
+
schedules = []
|
|
37
|
+
for _name, cfg in (data.get("schedules") or {}).items():
|
|
38
|
+
if not cfg.get("enabled", True):
|
|
39
|
+
continue
|
|
40
|
+
raw_time = cfg["time"]
|
|
41
|
+
hour, minute = (int(p) for p in raw_time.split(":"))
|
|
42
|
+
schedules.append(
|
|
43
|
+
cls(
|
|
44
|
+
command=cfg["command"],
|
|
45
|
+
prompt_file=cfg["prompt_file"],
|
|
46
|
+
run_time=time(hour, minute),
|
|
47
|
+
enabled=cfg.get("enabled", True),
|
|
48
|
+
retry_on_fail=cfg.get("retry_on_fail", True),
|
|
49
|
+
max_retries=cfg.get("max_retries", 2),
|
|
50
|
+
timeout_minutes=cfg.get("timeout_minutes", 60),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return schedules
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ArkaScheduler:
|
|
57
|
+
"""Cross-platform scheduler daemon for ArkaOS cognitive tasks."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config_path: str, log_dir: str, lock_path: str) -> None:
|
|
60
|
+
self._config_path = config_path
|
|
61
|
+
self._log_dir = log_dir
|
|
62
|
+
self._lock_path = lock_path
|
|
63
|
+
self._lock_fd = None
|
|
64
|
+
self.schedules: list[ScheduleConfig] = ScheduleConfig.load(config_path)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Lock management
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def acquire_lock(self) -> bool:
|
|
71
|
+
"""Acquire an exclusive file lock. Returns False if already locked."""
|
|
72
|
+
Path(self._lock_path).parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
try:
|
|
74
|
+
fd = open(self._lock_path, "w") # noqa: WPS515
|
|
75
|
+
if sys.platform == "win32":
|
|
76
|
+
import msvcrt # type: ignore[import]
|
|
77
|
+
|
|
78
|
+
msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1)
|
|
79
|
+
else:
|
|
80
|
+
import fcntl # type: ignore[import]
|
|
81
|
+
|
|
82
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
83
|
+
self._lock_fd = fd
|
|
84
|
+
return True
|
|
85
|
+
except (OSError, IOError):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def release_lock(self) -> None:
|
|
89
|
+
"""Release the file lock if held."""
|
|
90
|
+
if self._lock_fd is None:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
if sys.platform == "win32":
|
|
94
|
+
import msvcrt # type: ignore[import]
|
|
95
|
+
|
|
96
|
+
msvcrt.locking(self._lock_fd.fileno(), msvcrt.LK_UNLCK, 1)
|
|
97
|
+
else:
|
|
98
|
+
import fcntl # type: ignore[import]
|
|
99
|
+
|
|
100
|
+
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
|
101
|
+
finally:
|
|
102
|
+
self._lock_fd.close()
|
|
103
|
+
self._lock_fd = None
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Schedule logic
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _should_run(self, schedule: ScheduleConfig, current_time: time) -> bool:
|
|
110
|
+
"""Return True when current_time matches schedule's run_time (HH:MM)."""
|
|
111
|
+
return (
|
|
112
|
+
current_time.hour == schedule.run_time.hour
|
|
113
|
+
and current_time.minute == schedule.run_time.minute
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _build_command(self, schedule: ScheduleConfig) -> list[str]:
|
|
117
|
+
"""Build the Claude CLI invocation for a schedule."""
|
|
118
|
+
claude_bin = shutil.which("claude") or "claude"
|
|
119
|
+
prompt_path = os.path.expanduser(schedule.prompt_file)
|
|
120
|
+
prompt_content = Path(prompt_path).read_text(encoding="utf-8")
|
|
121
|
+
return [claude_bin, "-p", prompt_content, "--dangerously-skip-permissions"]
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Execution
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _log_path(self, command: str) -> Path:
|
|
128
|
+
"""Return the log file path for today's run of a command."""
|
|
129
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
130
|
+
log_dir = Path(self._log_dir) / command
|
|
131
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
return log_dir / f"{today}.log"
|
|
133
|
+
|
|
134
|
+
def execute(self, schedule: ScheduleConfig) -> bool:
|
|
135
|
+
"""Run the scheduled command, writing output to a dated log file."""
|
|
136
|
+
log_file = self._log_path(schedule.command)
|
|
137
|
+
timeout_seconds = schedule.timeout_minutes * 60
|
|
138
|
+
attempts = 0
|
|
139
|
+
max_attempts = schedule.max_retries + 1 if schedule.retry_on_fail else 1
|
|
140
|
+
|
|
141
|
+
while attempts < max_attempts:
|
|
142
|
+
attempts += 1
|
|
143
|
+
cmd = self._build_command(schedule)
|
|
144
|
+
with open(log_file, "a", encoding="utf-8") as lf:
|
|
145
|
+
lf.write(f"\n--- attempt {attempts} at {datetime.now().isoformat()} ---\n")
|
|
146
|
+
try:
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
cmd,
|
|
149
|
+
stdout=lf,
|
|
150
|
+
stderr=lf,
|
|
151
|
+
timeout=timeout_seconds,
|
|
152
|
+
)
|
|
153
|
+
if result.returncode == 0:
|
|
154
|
+
return True
|
|
155
|
+
lf.write(f"exit code: {result.returncode}\n")
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
lf.write("TIMEOUT\n")
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
lf.write(f"ERROR: {exc}\n")
|
|
160
|
+
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Main entry point
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def run_once(self) -> None:
|
|
168
|
+
"""Check all schedules against current time and execute matching ones."""
|
|
169
|
+
now = datetime.now().time().replace(second=0, microsecond=0)
|
|
170
|
+
for schedule in self.schedules:
|
|
171
|
+
if self._should_run(schedule, now):
|
|
172
|
+
self.execute(schedule)
|