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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/knight.js +253 -0
- package/package.json +43 -0
- package/scripts/compress-memory.py +147 -0
- package/scripts/heartbeat.py +200 -0
- package/scripts/knight-status.py +219 -0
- package/scripts/reflection-analyzer.py +319 -0
- package/scripts/write-reflection.py +132 -0
- package/src/chat.js +237 -0
- package/src/config.js +128 -0
- package/src/setup.js +420 -0
- package/templates/AGENTS.md +82 -0
- package/templates/HEARTBEAT.md +54 -0
- package/templates/MEMORY.md +65 -0
- package/templates/PROJECTS.md +60 -0
- package/templates/REDLINES.md +99 -0
- package/templates/SOUL.md +39 -0
- package/templates/TOOLS.md +43 -0
- package/templates/USER.md +63 -0
- package/templates/memory/TEMPLATE-daily.md +21 -0
- package/templates/memory/ai-patterns.md +90 -0
- package/templates/memory/user-patterns.md +52 -0
|
@@ -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()
|