@geravant/sinain 1.0.1
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/README.md +183 -0
- package/index.ts +2096 -0
- package/install.js +155 -0
- package/openclaw.plugin.json +59 -0
- package/package.json +21 -0
- package/sinain-memory/common.py +403 -0
- package/sinain-memory/demo_knowledge_transfer.sh +85 -0
- package/sinain-memory/embedder.py +268 -0
- package/sinain-memory/eval/__init__.py +0 -0
- package/sinain-memory/eval/assertions.py +288 -0
- package/sinain-memory/eval/judges/__init__.py +0 -0
- package/sinain-memory/eval/judges/base_judge.py +61 -0
- package/sinain-memory/eval/judges/curation_judge.py +46 -0
- package/sinain-memory/eval/judges/insight_judge.py +48 -0
- package/sinain-memory/eval/judges/mining_judge.py +42 -0
- package/sinain-memory/eval/judges/signal_judge.py +45 -0
- package/sinain-memory/eval/schemas.py +247 -0
- package/sinain-memory/eval_delta.py +109 -0
- package/sinain-memory/eval_reporter.py +642 -0
- package/sinain-memory/feedback_analyzer.py +221 -0
- package/sinain-memory/git_backup.sh +19 -0
- package/sinain-memory/insight_synthesizer.py +181 -0
- package/sinain-memory/memory/2026-03-01.md +11 -0
- package/sinain-memory/memory/playbook-archive/sinain-playbook-2026-03-01-1418.md +15 -0
- package/sinain-memory/memory/playbook-logs/2026-03-01.jsonl +1 -0
- package/sinain-memory/memory/sinain-playbook.md +21 -0
- package/sinain-memory/memory-config.json +39 -0
- package/sinain-memory/memory_miner.py +183 -0
- package/sinain-memory/module_manager.py +695 -0
- package/sinain-memory/playbook_curator.py +225 -0
- package/sinain-memory/requirements.txt +3 -0
- package/sinain-memory/signal_analyzer.py +141 -0
- package/sinain-memory/test_local.py +402 -0
- package/sinain-memory/tests/__init__.py +0 -0
- package/sinain-memory/tests/conftest.py +189 -0
- package/sinain-memory/tests/test_curator_helpers.py +94 -0
- package/sinain-memory/tests/test_embedder.py +210 -0
- package/sinain-memory/tests/test_extract_json.py +124 -0
- package/sinain-memory/tests/test_feedback_computation.py +121 -0
- package/sinain-memory/tests/test_miner_helpers.py +71 -0
- package/sinain-memory/tests/test_module_management.py +458 -0
- package/sinain-memory/tests/test_parsers.py +96 -0
- package/sinain-memory/tests/test_tick_evaluator.py +430 -0
- package/sinain-memory/tests/test_triple_extractor.py +255 -0
- package/sinain-memory/tests/test_triple_ingest.py +191 -0
- package/sinain-memory/tests/test_triple_migrate.py +138 -0
- package/sinain-memory/tests/test_triplestore.py +248 -0
- package/sinain-memory/tick_evaluator.py +392 -0
- package/sinain-memory/triple_extractor.py +402 -0
- package/sinain-memory/triple_ingest.py +290 -0
- package/sinain-memory/triple_migrate.py +275 -0
- package/sinain-memory/triple_query.py +184 -0
- package/sinain-memory/triplestore.py +498 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase 3 Step 3: Playbook Curator — archive and update sinain-playbook.md.
|
|
3
|
+
|
|
4
|
+
Archives current playbook, uses LLM to curate (add/prune/promote patterns),
|
|
5
|
+
writes the updated playbook back. Respects the curate directive from feedback_analyzer.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 playbook_curator.py --memory-dir memory/ --session-summary "..." \
|
|
9
|
+
[--curate-directive normal] [--mining-findings "TEXT"]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from common import (
|
|
20
|
+
LLMError,
|
|
21
|
+
call_llm_with_fallback,
|
|
22
|
+
extract_json,
|
|
23
|
+
output_json,
|
|
24
|
+
read_effective_playbook,
|
|
25
|
+
read_playbook,
|
|
26
|
+
read_recent_logs,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SYSTEM_PROMPT = """\
|
|
30
|
+
You are a playbook curator for a personal AI assistant (sinain).
|
|
31
|
+
Your job: maintain a concise, high-quality playbook of patterns and observations.
|
|
32
|
+
|
|
33
|
+
The playbook has these sections:
|
|
34
|
+
1. Header comments (mining-index) — DO NOT modify these
|
|
35
|
+
2. Patterns: "When [context], [approach] worked (score: X)" or failed patterns
|
|
36
|
+
3. User preference observations
|
|
37
|
+
4. Stale items with [since: YYYY-MM-DD] or [deferred: YYYY-MM-DD, reason: "..."] tags
|
|
38
|
+
5. Footer comments (effectiveness) — DO NOT modify these (managed by plugin)
|
|
39
|
+
|
|
40
|
+
Curate rules:
|
|
41
|
+
- ADD new successful patterns from recent sessions
|
|
42
|
+
- ADD failed patterns with reasons
|
|
43
|
+
- ADD user preference observations (recurring topics, tools, rhythms)
|
|
44
|
+
- PRUNE entries older than 7 days without reinforcement
|
|
45
|
+
- PROMOTE patterns seen 3+ times from "observed" to "established"
|
|
46
|
+
- Three Laws: (1) don't remove error-prevention patterns, (2) preserve high-scoring approaches, (3) then evolve
|
|
47
|
+
- Keep under 50 lines — density over completeness
|
|
48
|
+
- DEDUPLICATE: if multiple patterns describe the same concept (e.g., "idle standby" variants), merge them into ONE canonical pattern
|
|
49
|
+
- Before adding: check if the pattern already exists in the Active Module Patterns section — do NOT duplicate module patterns into the base playbook
|
|
50
|
+
|
|
51
|
+
Curate directive controls aggressiveness:
|
|
52
|
+
- "aggressive_prune": effectiveness is low — remove weak/unverified patterns aggressively
|
|
53
|
+
- "normal": balanced add/prune cycle
|
|
54
|
+
- "stability": effectiveness is high — only add patterns with strong evidence (score > 0.5)
|
|
55
|
+
- "insufficient_data": skip effectiveness adjustments, focus on gathering patterns
|
|
56
|
+
|
|
57
|
+
Stale item rules:
|
|
58
|
+
- New fixable patterns get [since: YYYY-MM-DD] tag
|
|
59
|
+
- 48h without change → mandatory Phase 2 action (not your concern, just keep the tag)
|
|
60
|
+
- After 3 actions without resolution → move to [deferred: YYYY-MM-DD, reason: "..."]
|
|
61
|
+
- Max 5 deferred items; if adding 6th, prune oldest deferred
|
|
62
|
+
|
|
63
|
+
Respond with ONLY a JSON object:
|
|
64
|
+
{
|
|
65
|
+
"updatedPlaybook": "full text of updated playbook (body only, between header/footer comments)",
|
|
66
|
+
"changes": {
|
|
67
|
+
"added": ["pattern text", ...],
|
|
68
|
+
"pruned": ["pattern text", ...],
|
|
69
|
+
"promoted": ["pattern text", ...]
|
|
70
|
+
},
|
|
71
|
+
"staleItemActions": ["description of stale item handling", ...]
|
|
72
|
+
}"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def archive_playbook(memory_dir: str) -> str | None:
|
|
76
|
+
"""Archive current playbook to playbook-archive/. Returns archive path or None."""
|
|
77
|
+
src = Path(memory_dir) / "sinain-playbook.md"
|
|
78
|
+
if not src.exists():
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
archive_dir = Path(memory_dir) / "playbook-archive"
|
|
82
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M")
|
|
85
|
+
dest = archive_dir / f"sinain-playbook-{ts}.md"
|
|
86
|
+
shutil.copy2(src, dest)
|
|
87
|
+
return str(dest)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_header_footer(playbook: str) -> tuple[str, str, str]:
|
|
91
|
+
"""Split playbook into (header_comments, body, footer_comments).
|
|
92
|
+
|
|
93
|
+
Header: lines starting with <!-- mining-index or other top comments
|
|
94
|
+
Footer: lines starting with <!-- effectiveness
|
|
95
|
+
Body: everything between
|
|
96
|
+
"""
|
|
97
|
+
lines = playbook.splitlines()
|
|
98
|
+
header_lines = []
|
|
99
|
+
footer_lines = []
|
|
100
|
+
body_lines = []
|
|
101
|
+
|
|
102
|
+
for line in lines:
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
if stripped.startswith("<!-- mining-index:"):
|
|
105
|
+
header_lines.append(line)
|
|
106
|
+
elif stripped.startswith("<!-- effectiveness:"):
|
|
107
|
+
footer_lines.append(line)
|
|
108
|
+
else:
|
|
109
|
+
body_lines.append(line)
|
|
110
|
+
|
|
111
|
+
return "\n".join(header_lines), "\n".join(body_lines), "\n".join(footer_lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reassemble_playbook(header: str, body: str, footer: str) -> str:
|
|
115
|
+
"""Reassemble playbook from header + body + footer, ensuring under 50 lines."""
|
|
116
|
+
parts = []
|
|
117
|
+
if header.strip():
|
|
118
|
+
parts.append(header.strip())
|
|
119
|
+
if body.strip():
|
|
120
|
+
parts.append(body.strip())
|
|
121
|
+
if footer.strip():
|
|
122
|
+
parts.append(footer.strip())
|
|
123
|
+
|
|
124
|
+
text = "\n\n".join(parts)
|
|
125
|
+
|
|
126
|
+
# Enforce 50-line limit on body (header/footer don't count)
|
|
127
|
+
body_lines = body.strip().splitlines()
|
|
128
|
+
if len(body_lines) > 50:
|
|
129
|
+
body = "\n".join(body_lines[:50])
|
|
130
|
+
text = "\n\n".join(filter(None, [header.strip(), body.strip(), footer.strip()]))
|
|
131
|
+
|
|
132
|
+
return text + "\n"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
parser = argparse.ArgumentParser(description="Phase 3: Playbook curation")
|
|
137
|
+
parser.add_argument("--memory-dir", required=True, help="Path to memory/ directory")
|
|
138
|
+
parser.add_argument("--session-summary", required=True, help="Brief session summary")
|
|
139
|
+
parser.add_argument("--curate-directive", default="normal",
|
|
140
|
+
choices=["aggressive_prune", "normal", "stability", "insufficient_data"],
|
|
141
|
+
help="Curation aggressiveness directive")
|
|
142
|
+
parser.add_argument("--mining-findings", default="", help="Findings from memory_miner (if available)")
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
playbook = read_playbook(args.memory_dir)
|
|
146
|
+
effective_playbook = read_effective_playbook(args.memory_dir)
|
|
147
|
+
logs = read_recent_logs(args.memory_dir, days=7)
|
|
148
|
+
|
|
149
|
+
# Archive before modification
|
|
150
|
+
archive_path = archive_playbook(args.memory_dir)
|
|
151
|
+
if archive_path:
|
|
152
|
+
print(f"[info] Archived playbook to {archive_path}", file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
header, body, footer = extract_header_footer(playbook)
|
|
155
|
+
|
|
156
|
+
# Build LLM prompt
|
|
157
|
+
log_summary = []
|
|
158
|
+
for entry in logs[:10]:
|
|
159
|
+
changes = entry.get("playbookChanges", {})
|
|
160
|
+
output = entry.get("output", {})
|
|
161
|
+
log_summary.append({
|
|
162
|
+
"ts": entry.get("ts", "?"),
|
|
163
|
+
"changes": changes,
|
|
164
|
+
"suggestion": (output.get("suggestion", "") if output else "")[:100],
|
|
165
|
+
"skipped": entry.get("skipped", False),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
169
|
+
user_prompt = (
|
|
170
|
+
f"## Current Date\n{today}\n\n"
|
|
171
|
+
f"## Curate Directive\n{args.curate_directive}\n\n"
|
|
172
|
+
f"## Session Summary\n{args.session_summary}\n\n"
|
|
173
|
+
f"## Current Playbook Body\n{body}\n\n"
|
|
174
|
+
f"## Recent Log Entries (last 10)\n{json.dumps(log_summary, indent=2)}\n\n"
|
|
175
|
+
)
|
|
176
|
+
if args.mining_findings:
|
|
177
|
+
user_prompt += f"## Mining Findings\n{args.mining_findings}\n\n"
|
|
178
|
+
|
|
179
|
+
if effective_playbook != playbook:
|
|
180
|
+
user_prompt += f"## Active Module Patterns (DO NOT duplicate these)\n{effective_playbook}\n\n"
|
|
181
|
+
|
|
182
|
+
user_prompt += (
|
|
183
|
+
"Curate the playbook. Return the FULL updated body text and a summary of changes.\n\n"
|
|
184
|
+
"IMPORTANT: Output ONLY the JSON object. No explanation, no reasoning, "
|
|
185
|
+
"no commentary — start your response with { and end with }."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
raw = call_llm_with_fallback(SYSTEM_PROMPT, user_prompt, script="playbook_curator", json_mode=True)
|
|
190
|
+
result = extract_json(raw)
|
|
191
|
+
except (ValueError, LLMError) as e:
|
|
192
|
+
print(f"[warn] {e}", file=sys.stderr)
|
|
193
|
+
output_json({
|
|
194
|
+
"changes": {"added": [], "pruned": [], "promoted": []},
|
|
195
|
+
"staleItemActions": [],
|
|
196
|
+
"playbookLines": len(body.splitlines()),
|
|
197
|
+
"error": "LLM response parse failed",
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Validate: updatedPlaybook should not be empty and should differ from input
|
|
202
|
+
updated_body = result.get("updatedPlaybook", "")
|
|
203
|
+
if not updated_body or not updated_body.strip():
|
|
204
|
+
print("[warn] LLM returned empty playbook body, keeping original", file=sys.stderr)
|
|
205
|
+
updated_body = body
|
|
206
|
+
elif updated_body.strip() == body.strip():
|
|
207
|
+
print("[info] Curator made no changes", file=sys.stderr)
|
|
208
|
+
|
|
209
|
+
# Write updated playbook
|
|
210
|
+
new_playbook = reassemble_playbook(header, updated_body, footer)
|
|
211
|
+
|
|
212
|
+
playbook_path = Path(args.memory_dir) / "sinain-playbook.md"
|
|
213
|
+
playbook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
playbook_path.write_text(new_playbook, encoding="utf-8")
|
|
215
|
+
|
|
216
|
+
body_lines = updated_body.strip().splitlines()
|
|
217
|
+
output_json({
|
|
218
|
+
"changes": result.get("changes", {"added": [], "pruned": [], "promoted": []}),
|
|
219
|
+
"staleItemActions": result.get("staleItemActions", []),
|
|
220
|
+
"playbookLines": len(body_lines),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase 2: Signal Analyzer — detect actionable signals from session context.
|
|
3
|
+
|
|
4
|
+
Reads playbook-logs (last 3 entries) + session summary, uses LLM to identify
|
|
5
|
+
signals and recommend actions (spawn subagent, send tip, or skip).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 signal_analyzer.py --memory-dir memory/ --session-summary "..." [--idle]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from common import (
|
|
15
|
+
LLMError,
|
|
16
|
+
call_llm,
|
|
17
|
+
extract_json,
|
|
18
|
+
output_json,
|
|
19
|
+
read_effective_playbook,
|
|
20
|
+
read_recent_logs,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = """\
|
|
24
|
+
You are a signal detection agent for a personal AI assistant system (sinain).
|
|
25
|
+
Your job: scan the user's recent session activity and detect actionable signals.
|
|
26
|
+
|
|
27
|
+
Signal types and recommended actions:
|
|
28
|
+
| Signal | Action |
|
|
29
|
+
|--------|--------|
|
|
30
|
+
| Error or issue repeated in context | sessions_spawn: "Find root cause for: [error]" |
|
|
31
|
+
| New tech/topic being explored | sessions_spawn: "Research [topic]: key findings, best practices, pitfalls" |
|
|
32
|
+
| Clear next action to suggest | telegram_tip: concise suggestion |
|
|
33
|
+
| User stuck (same search/error loop) | sessions_spawn: "Debug [issue]: investigate and propose fix" |
|
|
34
|
+
| No meaningful signal | skip |
|
|
35
|
+
|
|
36
|
+
Signal priority levels:
|
|
37
|
+
- HIGH: Repeated errors, build failures, stuck loops — always act
|
|
38
|
+
- MEDIUM: New topic exploration, workflow opportunity — act if confident
|
|
39
|
+
- LOW: General browsing, social media — skip unless highly relevant
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- Max 1 recommended action per analysis
|
|
43
|
+
- NEVER repeat an action that appears in recent log entries
|
|
44
|
+
- Prefer depth (spawn research) over breadth (generic tips)
|
|
45
|
+
- If idle (>30 min no activity), set idle=true and skip Phase 2 action
|
|
46
|
+
- Confidence: 0.0-1.0 (only recommend actions with confidence > 0.5)
|
|
47
|
+
|
|
48
|
+
Include a "priority" field ("high"/"medium"/"low") in each signal.
|
|
49
|
+
|
|
50
|
+
Respond with ONLY a JSON object (no markdown, no explanation):
|
|
51
|
+
{
|
|
52
|
+
"signals": [{"description": "signal text", "priority": "high|medium|low"}, ...],
|
|
53
|
+
"recommendedAction": {"action": "sessions_spawn|telegram_tip|skip", "task": "description if not skip", "confidence": 0.7} or null,
|
|
54
|
+
"idle": false
|
|
55
|
+
}"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_user_prompt(
|
|
59
|
+
session_summary: str,
|
|
60
|
+
recent_logs: list[dict],
|
|
61
|
+
playbook: str,
|
|
62
|
+
idle: bool,
|
|
63
|
+
current_time: str | None = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
parts = []
|
|
66
|
+
|
|
67
|
+
if current_time:
|
|
68
|
+
parts.append(f"## Current Time\n{current_time}")
|
|
69
|
+
|
|
70
|
+
parts.append(f"## Session Summary\n{session_summary}")
|
|
71
|
+
|
|
72
|
+
if idle:
|
|
73
|
+
parts.append("\n## Status: IDLE (>30 min no activity)\nSkip Phase 2 action. Only report if any background signals exist.")
|
|
74
|
+
|
|
75
|
+
if recent_logs:
|
|
76
|
+
log_summary = []
|
|
77
|
+
for entry in recent_logs[:6]:
|
|
78
|
+
actions = entry.get("actionsConsidered", [])
|
|
79
|
+
chosen = [a for a in actions if a.get("chosen")]
|
|
80
|
+
skipped = entry.get("skipped", False)
|
|
81
|
+
log_summary.append(
|
|
82
|
+
f"- ts={entry.get('ts', '?')}, idle={entry.get('idle', '?')}, "
|
|
83
|
+
f"actions_chosen={len(chosen)}, skipped={skipped}"
|
|
84
|
+
)
|
|
85
|
+
for a in chosen:
|
|
86
|
+
log_summary.append(f" -> {a.get('action', '?')}: {a.get('reason', '?')}")
|
|
87
|
+
parts.append(f"\n## Recent Log Entries (last 6)\n" + "\n".join(log_summary))
|
|
88
|
+
|
|
89
|
+
if playbook:
|
|
90
|
+
# Truncate playbook to first 50 lines for context
|
|
91
|
+
lines = playbook.splitlines()[:50]
|
|
92
|
+
parts.append(f"\n## Current Playbook (first 50 lines)\n" + "\n".join(lines))
|
|
93
|
+
|
|
94
|
+
return "\n".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main():
|
|
98
|
+
parser = argparse.ArgumentParser(description="Phase 2: Signal detection")
|
|
99
|
+
parser.add_argument("--memory-dir", required=True, help="Path to memory/ directory")
|
|
100
|
+
parser.add_argument("--session-summary", required=True, help="Brief session summary from main agent")
|
|
101
|
+
parser.add_argument("--idle", action="store_true", help="User is idle (>30 min)")
|
|
102
|
+
parser.add_argument("--current-time", default=None, help="Current local time string (e.g. 'Monday, 2 March 2026, 14:30 (Europe/Berlin)')")
|
|
103
|
+
args = parser.parse_args()
|
|
104
|
+
|
|
105
|
+
playbook = read_effective_playbook(args.memory_dir)
|
|
106
|
+
recent_logs = read_recent_logs(args.memory_dir, days=3)
|
|
107
|
+
|
|
108
|
+
user_prompt = build_user_prompt(
|
|
109
|
+
session_summary=args.session_summary,
|
|
110
|
+
recent_logs=recent_logs,
|
|
111
|
+
playbook=playbook,
|
|
112
|
+
idle=args.idle,
|
|
113
|
+
current_time=args.current_time,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
raw = call_llm(SYSTEM_PROMPT, user_prompt, script="signal_analyzer", json_mode=True)
|
|
118
|
+
result = extract_json(raw)
|
|
119
|
+
except (ValueError, LLMError) as e:
|
|
120
|
+
print(f"[warn] {e}", file=sys.stderr)
|
|
121
|
+
result = {
|
|
122
|
+
"signals": [],
|
|
123
|
+
"recommendedAction": None,
|
|
124
|
+
"idle": args.idle,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Suppress low-confidence actions
|
|
128
|
+
action = result.get("recommendedAction")
|
|
129
|
+
if action and isinstance(action, dict):
|
|
130
|
+
confidence = action.get("confidence", 0)
|
|
131
|
+
if confidence < 0.5:
|
|
132
|
+
result["recommendedAction"] = {"action": "skip", "task": None, "confidence": confidence}
|
|
133
|
+
print(f"[info] Suppressed low-confidence action ({confidence})", file=sys.stderr)
|
|
134
|
+
|
|
135
|
+
# Ensure idle flag matches CLI arg
|
|
136
|
+
result["idle"] = args.idle
|
|
137
|
+
output_json(result)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|