knight-os 0.1.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.
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ heartbeat.py — Platform-agnostic maintenance scheduler for Knight OS.
4
+
5
+ Executes periodic maintenance tasks:
6
+ 1. Reflection analysis — detect repeated failure patterns
7
+ 2. Memory scan — check MEMORY.md staleness
8
+ 3. Log compression — check logs directory size
9
+
10
+ Usage:
11
+ python3 scripts/heartbeat.py
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import subprocess
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+
22
+ def load_config():
23
+ config_paths = [
24
+ Path.cwd() / "knight.config.json",
25
+ Path.home() / ".knight" / "config.json",
26
+ ]
27
+ for p in config_paths:
28
+ if p.exists():
29
+ try:
30
+ return json.loads(p.read_text())
31
+ except (json.JSONDecodeError, OSError):
32
+ pass
33
+ return {}
34
+
35
+
36
+ def resolve_workspace(config):
37
+ ws = config.get("workspace", "~/.openclaw/workspace")
38
+ return Path(ws).expanduser()
39
+
40
+
41
+ def log(msg: str):
42
+ ts = datetime.now().strftime("%H:%M:%S")
43
+ print(f"[{ts}] {msg}", flush=True)
44
+
45
+
46
+ def step_reflection_analysis(config) -> str:
47
+ """Run reflection-analyzer.py to detect patterns."""
48
+ log("Step 1: Reflection analysis")
49
+ scripts_dir = Path(__file__).parent
50
+ analyzer = scripts_dir / "reflection-analyzer.py"
51
+
52
+ if not analyzer.exists():
53
+ log(" reflection-analyzer.py not found, skipping")
54
+ return "Reflection analysis: script not found"
55
+
56
+ try:
57
+ result = subprocess.run(
58
+ [sys.executable, str(analyzer)],
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=60,
62
+ cwd=str(scripts_dir.parent),
63
+ )
64
+ output = (result.stdout + result.stderr).strip()
65
+ if result.returncode == 0:
66
+ log(" Analysis complete")
67
+ pattern_count = output.count("candidate rule")
68
+ return f"Reflection analysis: complete ({pattern_count} patterns found)" if pattern_count else "Reflection analysis: no patterns"
69
+ else:
70
+ log(f" Analysis failed: {output[:200]}")
71
+ return f"Reflection analysis: failed"
72
+ except subprocess.TimeoutExpired:
73
+ log(" Analysis timed out")
74
+ return "Reflection analysis: timed out"
75
+ except Exception as e:
76
+ log(f" Analysis error: {e}")
77
+ return f"Reflection analysis: error"
78
+
79
+
80
+ def step_memory_scan(config) -> str:
81
+ """Check MEMORY.md freshness — warn if older than 7 days."""
82
+ log("Step 2: Memory scan")
83
+ workspace = resolve_workspace(config)
84
+ local_cfg = config.get("storage", {}).get("local", {})
85
+ memory_file = workspace / local_cfg.get("memory_file", "MEMORY.md")
86
+
87
+ if not memory_file.exists():
88
+ log(" MEMORY.md not found")
89
+ return "Memory scan: MEMORY.md not found"
90
+
91
+ mtime = datetime.fromtimestamp(memory_file.stat().st_mtime, tz=timezone.utc)
92
+ age_days = (datetime.now(timezone.utc) - mtime).days
93
+
94
+ if age_days > 7:
95
+ log(f" MEMORY.md is {age_days} days old — consider updating")
96
+ return f"Memory scan: MEMORY.md is {age_days} days old (consider updating)"
97
+ else:
98
+ log(f" MEMORY.md is {age_days} days old — fresh")
99
+ return f"Memory scan: MEMORY.md is fresh ({age_days} days old)"
100
+
101
+
102
+ def step_log_compress(config) -> str:
103
+ """Check logs directory size and suggest compression if needed."""
104
+ log("Step 3: Log compression check")
105
+ workspace = resolve_workspace(config)
106
+ local_cfg = config.get("storage", {}).get("local", {})
107
+ logs_dir = workspace / local_cfg.get("logs_dir", "memory/logs")
108
+
109
+ if not logs_dir.exists():
110
+ log(" Logs directory not found")
111
+ return "Log compression: no logs directory"
112
+
113
+ total_lines = 0
114
+ total_size = 0
115
+ file_count = 0
116
+
117
+ for f in logs_dir.iterdir():
118
+ if f.is_file() and f.suffix in (".md", ".log", ".jsonl", ".txt"):
119
+ total_size += f.stat().st_size
120
+ file_count += 1
121
+ try:
122
+ total_lines += sum(1 for _ in open(f, encoding="utf-8", errors="ignore"))
123
+ except OSError:
124
+ pass
125
+
126
+ size_mb = total_size / (1024 * 1024)
127
+
128
+ if total_lines > 500:
129
+ log(f" {file_count} log files, {total_lines} lines ({size_mb:.1f} MB) — compression recommended")
130
+ return f"Log compression: {total_lines} lines across {file_count} files — run compress-memory.py"
131
+ else:
132
+ log(f" {file_count} log files, {total_lines} lines ({size_mb:.1f} MB) — OK")
133
+ return f"Log compression: OK ({total_lines} lines, {size_mb:.1f} MB)"
134
+
135
+
136
+ def send_notification(report: str, config: dict):
137
+ """Send heartbeat report via configured notification backend."""
138
+ notifications = config.get("notifications", {})
139
+ backend = notifications.get("backend", "none")
140
+
141
+ if backend == "telegram" or notifications.get("telegram", {}).get("enabled"):
142
+ import urllib.request
143
+
144
+ telegram_cfg = notifications.get("telegram", {})
145
+ bot_token = telegram_cfg.get("bot_token", "") or os.environ.get("TELEGRAM_BOT_TOKEN", "")
146
+ chat_id = telegram_cfg.get("chat_id", "") or os.environ.get("TELEGRAM_CHAT_ID", "")
147
+
148
+ if bot_token and chat_id:
149
+ payload = {"chat_id": chat_id, "text": report}
150
+ req = urllib.request.Request(
151
+ f"https://api.telegram.org/bot{bot_token}/sendMessage",
152
+ data=json.dumps(payload).encode(),
153
+ headers={"Content-Type": "application/json"},
154
+ method="POST",
155
+ )
156
+ try:
157
+ with urllib.request.urlopen(req, timeout=10):
158
+ log(" Notification sent via Telegram")
159
+ except Exception as e:
160
+ log(f" Telegram notification failed: {e}")
161
+
162
+
163
+ def main():
164
+ start = datetime.now()
165
+ log(f"=== Knight Heartbeat started {start.strftime('%Y-%m-%d %H:%M')} ===")
166
+
167
+ config = load_config()
168
+ heartbeat_cfg = config.get("heartbeat", {})
169
+ tasks = heartbeat_cfg.get("tasks", ["reflection_analysis", "memory_scan", "log_compress"])
170
+
171
+ results = []
172
+
173
+ if "reflection_analysis" in tasks:
174
+ results.append(step_reflection_analysis(config))
175
+
176
+ if "memory_scan" in tasks:
177
+ results.append(step_memory_scan(config))
178
+
179
+ if "log_compress" in tasks:
180
+ results.append(step_log_compress(config))
181
+
182
+ now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
183
+ report_lines = [f"[knight] Heartbeat Report — {now_str}", ""]
184
+ for r in results:
185
+ if r:
186
+ report_lines.append(f" * {r}")
187
+
188
+ elapsed = (datetime.now() - start).seconds
189
+ report_lines.append(f"\n Completed in {elapsed}s")
190
+
191
+ report = "\n".join(report_lines)
192
+ print(report)
193
+
194
+ send_notification(report, config)
195
+
196
+ log(f"=== Knight Heartbeat complete ({elapsed}s) ===")
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ knight-status.py — Comprehensive workspace health check.
4
+
5
+ Usage:
6
+ python3 scripts/knight-status.py
7
+
8
+ Checks:
9
+ - Workspace directory existence and key files
10
+ - Reflections count and latest entry
11
+ - MEMORY.md last update time
12
+ - Logs directory size
13
+ - Heartbeat configuration status
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+
23
+ def load_config():
24
+ config_paths = [
25
+ Path.cwd() / "knight.config.json",
26
+ Path.home() / ".knight" / "config.json",
27
+ ]
28
+ for p in config_paths:
29
+ if p.exists():
30
+ try:
31
+ return json.loads(p.read_text())
32
+ except (json.JSONDecodeError, OSError):
33
+ pass
34
+ return {}
35
+
36
+
37
+ def resolve_workspace(config):
38
+ ws = config.get("workspace", "~/.openclaw/workspace")
39
+ return Path(ws).expanduser()
40
+
41
+
42
+ def check_workspace(workspace: Path) -> list:
43
+ """Check workspace directory and key files."""
44
+ results = []
45
+
46
+ if not workspace.exists():
47
+ results.append(("Workspace", "MISSING", str(workspace)))
48
+ return results
49
+
50
+ results.append(("Workspace", "OK", str(workspace)))
51
+
52
+ key_files = [
53
+ "AGENTS.md",
54
+ "SOUL.md",
55
+ "MEMORY.md",
56
+ "HEARTBEAT.md",
57
+ "REDLINES.md",
58
+ "USER.md",
59
+ "TOOLS.md",
60
+ "memory/ai-patterns.md",
61
+ "memory/user-patterns.md",
62
+ ]
63
+
64
+ present = 0
65
+ for f in key_files:
66
+ if (workspace / f).exists():
67
+ present += 1
68
+
69
+ results.append(("Core files", f"{present}/{len(key_files)}", "present"))
70
+ return results
71
+
72
+
73
+ def check_reflections(workspace: Path, config: dict) -> list:
74
+ """Check reflections directory."""
75
+ results = []
76
+ local_cfg = config.get("storage", {}).get("local", {})
77
+ reflections_dir = workspace / local_cfg.get("reflections_dir", "memory/reflections")
78
+
79
+ if not reflections_dir.exists():
80
+ results.append(("Reflections", "NONE", "directory not found"))
81
+ return results
82
+
83
+ jsonl_files = list(reflections_dir.glob("*.jsonl"))
84
+ total_entries = 0
85
+ latest_date = None
86
+
87
+ for f in sorted(jsonl_files):
88
+ try:
89
+ lines = [l for l in open(f, encoding="utf-8") if l.strip()]
90
+ total_entries += len(lines)
91
+ if lines:
92
+ last = json.loads(lines[-1])
93
+ ts = last.get("created_at", "")
94
+ if ts and (latest_date is None or ts > latest_date):
95
+ latest_date = ts
96
+ except (OSError, json.JSONDecodeError):
97
+ pass
98
+
99
+ if total_entries == 0:
100
+ results.append(("Reflections", "EMPTY", f"{len(jsonl_files)} files, 0 entries"))
101
+ else:
102
+ latest_str = latest_date[:10] if latest_date else "unknown"
103
+ results.append(("Reflections", "OK", f"{total_entries} entries across {len(jsonl_files)} files (latest: {latest_str})"))
104
+
105
+ return results
106
+
107
+
108
+ def check_memory(workspace: Path, config: dict) -> list:
109
+ """Check MEMORY.md freshness."""
110
+ results = []
111
+ local_cfg = config.get("storage", {}).get("local", {})
112
+ memory_file = workspace / local_cfg.get("memory_file", "MEMORY.md")
113
+
114
+ if not memory_file.exists():
115
+ results.append(("MEMORY.md", "MISSING", "not found"))
116
+ return results
117
+
118
+ mtime = datetime.fromtimestamp(memory_file.stat().st_mtime, tz=timezone.utc)
119
+ age_days = (datetime.now(timezone.utc) - mtime).days
120
+ status = "OK" if age_days <= 7 else "STALE"
121
+ results.append(("MEMORY.md", status, f"last modified {age_days} days ago"))
122
+ return results
123
+
124
+
125
+ def check_logs(workspace: Path, config: dict) -> list:
126
+ """Check logs directory size."""
127
+ results = []
128
+ local_cfg = config.get("storage", {}).get("local", {})
129
+ logs_dir = workspace / local_cfg.get("logs_dir", "memory/logs")
130
+
131
+ if not logs_dir.exists():
132
+ results.append(("Logs", "NONE", "directory not found"))
133
+ return results
134
+
135
+ total_size = 0
136
+ total_lines = 0
137
+ file_count = 0
138
+
139
+ for f in logs_dir.iterdir():
140
+ if f.is_file() and f.suffix in (".md", ".log", ".jsonl", ".txt"):
141
+ total_size += f.stat().st_size
142
+ file_count += 1
143
+ try:
144
+ total_lines += sum(1 for _ in open(f, encoding="utf-8", errors="ignore"))
145
+ except OSError:
146
+ pass
147
+
148
+ size_mb = total_size / (1024 * 1024)
149
+ status = "OK" if total_lines <= 500 else "LARGE"
150
+ results.append(("Logs", status, f"{file_count} files, {total_lines} lines, {size_mb:.2f} MB"))
151
+ return results
152
+
153
+
154
+ def check_heartbeat(config: dict) -> list:
155
+ """Check heartbeat configuration."""
156
+ results = []
157
+ heartbeat_cfg = config.get("heartbeat", {})
158
+
159
+ if heartbeat_cfg.get("enabled"):
160
+ interval = heartbeat_cfg.get("interval_hours", 6)
161
+ tasks = heartbeat_cfg.get("tasks", [])
162
+ results.append(("Heartbeat", "ENABLED", f"every {interval}h, tasks: {', '.join(tasks)}"))
163
+ else:
164
+ results.append(("Heartbeat", "DISABLED", "set heartbeat.enabled=true in config to activate"))
165
+
166
+ return results
167
+
168
+
169
+ def check_notifications(config: dict) -> list:
170
+ """Check notification configuration."""
171
+ results = []
172
+ notifications = config.get("notifications", {})
173
+ backend = notifications.get("backend", "none")
174
+
175
+ if backend == "none" and not notifications.get("telegram", {}).get("enabled"):
176
+ results.append(("Notifications", "DISABLED", "output to terminal only"))
177
+ elif notifications.get("telegram", {}).get("enabled"):
178
+ results.append(("Notifications", "TELEGRAM", "configured"))
179
+ else:
180
+ results.append(("Notifications", backend.upper(), "configured"))
181
+
182
+ return results
183
+
184
+
185
+ def main():
186
+ config = load_config()
187
+ workspace = resolve_workspace(config)
188
+ ai_name = config.get("ai_name", "Knight")
189
+
190
+ print(f"\n[knight] {ai_name} Workspace Health Report")
191
+ print(f"{'=' * 50}")
192
+
193
+ all_results = []
194
+ all_results.extend(check_workspace(workspace))
195
+ all_results.extend(check_reflections(workspace, config))
196
+ all_results.extend(check_memory(workspace, config))
197
+ all_results.extend(check_logs(workspace, config))
198
+ all_results.extend(check_heartbeat(config))
199
+ all_results.extend(check_notifications(config))
200
+
201
+ max_label = max(len(r[0]) for r in all_results)
202
+ max_status = max(len(r[1]) for r in all_results)
203
+
204
+ for label, status, detail in all_results:
205
+ icon = {
206
+ "OK": "+", "ENABLED": "+", "TELEGRAM": "+",
207
+ "MISSING": "!", "STALE": "!", "LARGE": "!",
208
+ "NONE": "-", "EMPTY": "-", "DISABLED": "-",
209
+ }.get(status, " ")
210
+ print(f" [{icon}] {label:<{max_label}} {status:<{max_status}} {detail}")
211
+
212
+ print(f"\n{'=' * 50}")
213
+ print(f" Config sources: knight.config.json, ~/.knight/config.json")
214
+ print(f" Storage backend: {config.get('storage', {}).get('backend', 'local')}")
215
+ print("")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()