@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.
Files changed (53) hide show
  1. package/README.md +183 -0
  2. package/index.ts +2096 -0
  3. package/install.js +155 -0
  4. package/openclaw.plugin.json +59 -0
  5. package/package.json +21 -0
  6. package/sinain-memory/common.py +403 -0
  7. package/sinain-memory/demo_knowledge_transfer.sh +85 -0
  8. package/sinain-memory/embedder.py +268 -0
  9. package/sinain-memory/eval/__init__.py +0 -0
  10. package/sinain-memory/eval/assertions.py +288 -0
  11. package/sinain-memory/eval/judges/__init__.py +0 -0
  12. package/sinain-memory/eval/judges/base_judge.py +61 -0
  13. package/sinain-memory/eval/judges/curation_judge.py +46 -0
  14. package/sinain-memory/eval/judges/insight_judge.py +48 -0
  15. package/sinain-memory/eval/judges/mining_judge.py +42 -0
  16. package/sinain-memory/eval/judges/signal_judge.py +45 -0
  17. package/sinain-memory/eval/schemas.py +247 -0
  18. package/sinain-memory/eval_delta.py +109 -0
  19. package/sinain-memory/eval_reporter.py +642 -0
  20. package/sinain-memory/feedback_analyzer.py +221 -0
  21. package/sinain-memory/git_backup.sh +19 -0
  22. package/sinain-memory/insight_synthesizer.py +181 -0
  23. package/sinain-memory/memory/2026-03-01.md +11 -0
  24. package/sinain-memory/memory/playbook-archive/sinain-playbook-2026-03-01-1418.md +15 -0
  25. package/sinain-memory/memory/playbook-logs/2026-03-01.jsonl +1 -0
  26. package/sinain-memory/memory/sinain-playbook.md +21 -0
  27. package/sinain-memory/memory-config.json +39 -0
  28. package/sinain-memory/memory_miner.py +183 -0
  29. package/sinain-memory/module_manager.py +695 -0
  30. package/sinain-memory/playbook_curator.py +225 -0
  31. package/sinain-memory/requirements.txt +3 -0
  32. package/sinain-memory/signal_analyzer.py +141 -0
  33. package/sinain-memory/test_local.py +402 -0
  34. package/sinain-memory/tests/__init__.py +0 -0
  35. package/sinain-memory/tests/conftest.py +189 -0
  36. package/sinain-memory/tests/test_curator_helpers.py +94 -0
  37. package/sinain-memory/tests/test_embedder.py +210 -0
  38. package/sinain-memory/tests/test_extract_json.py +124 -0
  39. package/sinain-memory/tests/test_feedback_computation.py +121 -0
  40. package/sinain-memory/tests/test_miner_helpers.py +71 -0
  41. package/sinain-memory/tests/test_module_management.py +458 -0
  42. package/sinain-memory/tests/test_parsers.py +96 -0
  43. package/sinain-memory/tests/test_tick_evaluator.py +430 -0
  44. package/sinain-memory/tests/test_triple_extractor.py +255 -0
  45. package/sinain-memory/tests/test_triple_ingest.py +191 -0
  46. package/sinain-memory/tests/test_triple_migrate.py +138 -0
  47. package/sinain-memory/tests/test_triplestore.py +248 -0
  48. package/sinain-memory/tick_evaluator.py +392 -0
  49. package/sinain-memory/triple_extractor.py +402 -0
  50. package/sinain-memory/triple_ingest.py +290 -0
  51. package/sinain-memory/triple_migrate.py +275 -0
  52. package/sinain-memory/triple_query.py +184 -0
  53. package/sinain-memory/triplestore.py +498 -0
package/install.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { execSync } from "child_process";
6
+
7
+ const HOME = os.homedir();
8
+ const PLUGIN_DIR = path.join(HOME, ".openclaw/extensions/sinain");
9
+ const SOURCES_DIR = path.join(HOME, ".openclaw/sinain-sources");
10
+ const OC_JSON = path.join(HOME, ".openclaw/openclaw.json");
11
+ const WORKSPACE = path.join(HOME, ".openclaw/workspace");
12
+
13
+ // PKG_DIR = sinain-hud-plugin/ inside the npm package
14
+ const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
15
+ const MEMORY_SRC = path.join(PKG_DIR, "sinain-memory");
16
+ const HEARTBEAT = path.join(PKG_DIR, "HEARTBEAT.md");
17
+
18
+ console.log("\nInstalling sinain plugin...");
19
+
20
+ // 1. Copy plugin files
21
+ fs.mkdirSync(PLUGIN_DIR, { recursive: true });
22
+ fs.copyFileSync(path.join(PKG_DIR, "index.ts"), path.join(PLUGIN_DIR, "index.ts"));
23
+ fs.copyFileSync(path.join(PKG_DIR, "openclaw.plugin.json"), path.join(PLUGIN_DIR, "openclaw.plugin.json"));
24
+ console.log(" ✓ Plugin files copied");
25
+
26
+ // 2. Copy sinain-memory from bundled package files
27
+ fs.mkdirSync(SOURCES_DIR, { recursive: true });
28
+ const memoryDst = path.join(SOURCES_DIR, "sinain-memory");
29
+ copyDir(MEMORY_SRC, memoryDst);
30
+ console.log(" ✓ sinain-memory copied");
31
+
32
+ // 3. Copy HEARTBEAT.md
33
+ fs.copyFileSync(HEARTBEAT, path.join(SOURCES_DIR, "HEARTBEAT.md"));
34
+ console.log(" ✓ HEARTBEAT.md copied");
35
+
36
+ // 4. Install Python deps
37
+ const reqFile = path.join(memoryDst, "requirements.txt");
38
+ if (fs.existsSync(reqFile)) {
39
+ console.log(" Installing Python dependencies...");
40
+ try {
41
+ execSync(`pip3 install -r "${reqFile}" --quiet`, { stdio: "inherit" });
42
+ console.log(" ✓ Python dependencies installed");
43
+ } catch {
44
+ console.warn(" ⚠ pip3 unavailable — Python eval features disabled");
45
+ }
46
+ }
47
+
48
+ // 5. Patch openclaw.json
49
+ let cfg = {};
50
+ if (fs.existsSync(OC_JSON)) {
51
+ try { cfg = JSON.parse(fs.readFileSync(OC_JSON, "utf8")); } catch {}
52
+ }
53
+ cfg.plugins ??= {};
54
+ cfg.plugins.entries ??= {};
55
+ cfg.plugins.entries["sinain"] = {
56
+ enabled: true,
57
+ config: {
58
+ heartbeatPath: path.join(SOURCES_DIR, "HEARTBEAT.md"),
59
+ memoryPath: memoryDst,
60
+ sessionKey: "agent:main:sinain"
61
+ }
62
+ };
63
+ cfg.agents ??= {};
64
+ cfg.agents.defaults ??= {};
65
+ cfg.agents.defaults.sandbox ??= {};
66
+ cfg.agents.defaults.sandbox.sessionToolsVisibility = "all";
67
+ cfg.compaction = { mode: "safeguard", maxHistoryShare: 0.2, reserveTokensFloor: 40000 };
68
+ cfg.gateway ??= {};
69
+ cfg.gateway.bind = "lan"; // allow remote Mac to connect
70
+
71
+ fs.mkdirSync(path.dirname(OC_JSON), { recursive: true });
72
+ fs.writeFileSync(OC_JSON, JSON.stringify(cfg, null, 2));
73
+ console.log(" ✓ openclaw.json patched");
74
+
75
+ // 6. Memory restore from backup repo (if SINAIN_BACKUP_REPO is set)
76
+ const backupUrl = process.env.SINAIN_BACKUP_REPO;
77
+ if (backupUrl) {
78
+ try {
79
+ await checkRepoPrivacy(backupUrl);
80
+ if (!fs.existsSync(path.join(WORKSPACE, ".git"))) {
81
+ console.log(" Restoring memory from backup repo...");
82
+ execSync(`git clone "${backupUrl}" "${WORKSPACE}" --quiet`, { stdio: "inherit" });
83
+ console.log(" ✓ Memory restored from", backupUrl);
84
+ } else {
85
+ execSync(`git -C "${WORKSPACE}" remote set-url origin "${backupUrl}"`, { stdio: "pipe" });
86
+ console.log(" ✓ Workspace git remote updated");
87
+ }
88
+ } catch (e) {
89
+ console.error("\n ✗ Memory restore aborted:", e.message, "\n");
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ // 7. Reload gateway
95
+ try {
96
+ execSync("openclaw reload", { stdio: "pipe" });
97
+ console.log(" ✓ Gateway reloaded");
98
+ } catch {
99
+ try {
100
+ execSync("openclaw stop && sleep 1 && openclaw start --background", { stdio: "pipe" });
101
+ console.log(" ✓ Gateway restarted");
102
+ } catch {
103
+ console.warn(" ⚠ Could not reload gateway — restart manually");
104
+ }
105
+ }
106
+
107
+ console.log("\n✓ sinain installed successfully.");
108
+ console.log(" Plugin config: ~/.openclaw/openclaw.json");
109
+ console.log(" Auth token: check your Brev dashboard → 'Gateway Token'");
110
+ console.log(" Then run ./setup-nemoclaw.sh on your Mac.\n");
111
+
112
+ // ── Helpers ──────────────────────────────────────────────────────────────────
113
+
114
+ function copyDir(src, dst) {
115
+ fs.mkdirSync(dst, { recursive: true });
116
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
117
+ const s = path.join(src, entry.name);
118
+ const d = path.join(dst, entry.name);
119
+ // Skip __pycache__ and .pytest_cache to keep the deploy lean
120
+ if (entry.name === "__pycache__" || entry.name === ".pytest_cache") continue;
121
+ entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
122
+ }
123
+ }
124
+
125
+ async function checkRepoPrivacy(url) {
126
+ const m = url.match(/(?:https:\/\/github\.com\/|git@github\.com:)([^/]+\/[^.]+)/);
127
+ if (!m) {
128
+ if (!process.env.SINAIN_BACKUP_REPO_CONFIRMED) {
129
+ throw new Error(
130
+ "Non-GitHub repo: cannot verify privacy.\n" +
131
+ "Set SINAIN_BACKUP_REPO_CONFIRMED=1 only if you are CERTAIN the repo is private."
132
+ );
133
+ }
134
+ return;
135
+ }
136
+ const ownerRepo = m[1].replace(/\.git$/, "");
137
+ let res;
138
+ try {
139
+ res = await fetch(`https://api.github.com/repos/${ownerRepo}`);
140
+ } catch (e) {
141
+ throw new Error(`Cannot reach GitHub to verify repo privacy: ${e.message}. Aborting.`);
142
+ }
143
+ if (res.status === 200) {
144
+ const data = await res.json();
145
+ if (!data.private) {
146
+ throw new Error(
147
+ `SECURITY: github.com/${ownerRepo} is PUBLIC.\n` +
148
+ `Make it private: github.com/${ownerRepo}/settings → Change visibility → Private`
149
+ );
150
+ }
151
+ } else if (res.status !== 404) {
152
+ throw new Error(`Cannot verify repo privacy (HTTP ${res.status}). Aborting for safety.`);
153
+ }
154
+ // 404 = private (unauthenticated can't see it) — OK
155
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "id": "sinain-hud",
3
+ "name": "SinainHUD Integration",
4
+ "description": "OpenClaw plugin for sinain-hud: auto-deploys HEARTBEAT/SKILL files, tracks tool usage patterns, generates structured session summaries, and manages workspace lifecycle.",
5
+ "version": "1.0.0",
6
+ "skills": [],
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "heartbeatPath": {
12
+ "type": "string",
13
+ "description": "Local path to HEARTBEAT.md source file"
14
+ },
15
+ "skillPath": {
16
+ "type": "string",
17
+ "description": "Local path to SKILL.md source file"
18
+ },
19
+ "memoryPath": {
20
+ "type": "string",
21
+ "description": "Local path to sinain-memory/ scripts directory"
22
+ },
23
+ "modulesPath": {
24
+ "type": "string",
25
+ "description": "Local path to modules/ directory for knowledge module system"
26
+ },
27
+ "sessionKey": {
28
+ "type": "string",
29
+ "description": "Session key for the sinain agent"
30
+ },
31
+ "userTimezone": {
32
+ "type": "string",
33
+ "description": "IANA timezone for time-aware context injection (e.g. Europe/Berlin)"
34
+ }
35
+ }
36
+ },
37
+ "uiHints": {
38
+ "heartbeatPath": {
39
+ "label": "HEARTBEAT.md Path",
40
+ "placeholder": "/path/to/HEARTBEAT.md",
41
+ "help": "Source path for auto-deploying HEARTBEAT.md to workspace"
42
+ },
43
+ "skillPath": {
44
+ "label": "SKILL.md Path",
45
+ "placeholder": "/path/to/SKILL.md",
46
+ "help": "Source path for auto-deploying SKILL.md to workspace"
47
+ },
48
+ "modulesPath": {
49
+ "label": "Modules Path",
50
+ "placeholder": "sinain-sources/modules",
51
+ "help": "Local path to modules/ directory for hot-swappable knowledge modules"
52
+ },
53
+ "sessionKey": {
54
+ "label": "Session Key",
55
+ "placeholder": "agent:main:sinain",
56
+ "help": "OpenClaw session key for the sinain agent"
57
+ }
58
+ }
59
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@geravant/sinain",
3
+ "version": "1.0.1",
4
+ "description": "sinain OpenClaw plugin — AI overlay for macOS",
5
+ "type": "module",
6
+ "bin": {
7
+ "sinain": "./install.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node install.js"
11
+ },
12
+ "files": [
13
+ "index.ts",
14
+ "openclaw.plugin.json",
15
+ "install.js",
16
+ "sinain-memory",
17
+ "HEARTBEAT.md"
18
+ ],
19
+ "engines": { "node": ">=18" },
20
+ "license": "MIT"
21
+ }
@@ -0,0 +1,403 @@
1
+ """Shared utilities for sinain-koog heartbeat scripts.
2
+
3
+ Centralizes OpenRouter API calls, memory/ file readers, and JSON output.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ from datetime import datetime, timedelta, timezone
12
+ from functools import lru_cache
13
+ from glob import glob
14
+ from pathlib import Path
15
+
16
+ import requests
17
+
18
+ MODEL_FAST = "google/gemini-3-flash-preview"
19
+ MODEL_SMART = "anthropic/claude-sonnet-4.6"
20
+ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
21
+
22
+
23
+ class LLMError(Exception):
24
+ """Raised when the LLM API call fails (timeout, network, bad response)."""
25
+ pass
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Robust JSON extraction from LLM responses
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def extract_json(text: str) -> dict | list:
33
+ """Extract a JSON object or array from potentially messy LLM output.
34
+
35
+ Three-stage extraction:
36
+ 1. Direct json.loads (clean case)
37
+ 2. Regex extraction from markdown code fences
38
+ 3. Balanced-brace scanner for JSON embedded in prose
39
+
40
+ Raises ValueError if no valid JSON can be extracted.
41
+ """
42
+ text = text.strip()
43
+
44
+ # Stage 1: direct parse
45
+ try:
46
+ return json.loads(text)
47
+ except json.JSONDecodeError:
48
+ pass
49
+
50
+ # Stage 2: markdown code fences ```json ... ``` or ``` ... ```
51
+ fence_match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
52
+ if fence_match:
53
+ try:
54
+ return json.loads(fence_match.group(1).strip())
55
+ except json.JSONDecodeError:
56
+ pass
57
+
58
+ # Stage 3+4: balanced-brace scanner with truncated JSON repair
59
+ # Uses a full bracket stack so nested {/[ are tracked together.
60
+ for open_ch, close_ch in (("{", "}"), ("[", "]")):
61
+ start = text.find(open_ch)
62
+ if start == -1:
63
+ continue
64
+ stack: list[str] = []
65
+ in_string = False
66
+ escape = False
67
+ string_start = -1
68
+ for i in range(start, len(text)):
69
+ ch = text[i]
70
+ if escape:
71
+ escape = False
72
+ continue
73
+ if ch == "\\":
74
+ if in_string:
75
+ escape = True
76
+ continue
77
+ if ch == '"':
78
+ if not in_string:
79
+ string_start = i
80
+ in_string = not in_string
81
+ continue
82
+ if in_string:
83
+ continue
84
+ if ch in ("{", "["):
85
+ stack.append("}" if ch == "{" else "]")
86
+ elif ch in ("}", "]"):
87
+ if stack:
88
+ stack.pop()
89
+ if not stack:
90
+ try:
91
+ return json.loads(text[start : i + 1])
92
+ except json.JSONDecodeError:
93
+ break # malformed — try next bracket type
94
+ else:
95
+ # Reached end of text with unclosed brackets — attempt repair
96
+ if not stack:
97
+ continue
98
+ closers = "".join(reversed(stack))
99
+ fragment = text[start:]
100
+
101
+ # Strategy A: if mid-string, close it then close all brackets
102
+ if in_string:
103
+ try:
104
+ return json.loads(fragment + '"' + closers)
105
+ except json.JSONDecodeError:
106
+ pass
107
+
108
+ # Strategy B: strip trailing incomplete tokens, close brackets
109
+ stripped = re.sub(r'[,:\s]+$', '', fragment)
110
+ try:
111
+ return json.loads(stripped + closers)
112
+ except json.JSONDecodeError:
113
+ pass
114
+
115
+ # Strategy C: if mid-string, cut before the unclosed string,
116
+ # strip trailing tokens, close brackets
117
+ if in_string and string_start >= start:
118
+ before_str = text[start:string_start]
119
+ before_str = re.sub(r'[,:\s]+$', '', before_str)
120
+ try:
121
+ return json.loads(before_str + closers)
122
+ except json.JSONDecodeError:
123
+ pass
124
+
125
+ raise ValueError(f"No valid JSON found in LLM response ({len(text)} chars): {text[:120]}...")
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # External config (koog-config.json)
130
+ # ---------------------------------------------------------------------------
131
+
132
+ @lru_cache(maxsize=1)
133
+ def _load_config() -> dict:
134
+ """Load koog-config.json from the same directory as this module. Cached."""
135
+ config_path = Path(__file__).resolve().parent / "koog-config.json"
136
+ try:
137
+ return json.loads(config_path.read_text(encoding="utf-8"))
138
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
139
+ print(f"[warn] koog-config.json not loaded: {exc}", file=sys.stderr)
140
+ return {}
141
+
142
+
143
+ def _resolve_model(logical_name: str) -> str:
144
+ """Map a logical model name ('fast'/'smart') to an actual model ID via config."""
145
+ cfg = _load_config()
146
+ models = cfg.get("models", {})
147
+ return models.get(logical_name, logical_name)
148
+
149
+
150
+ def call_llm(
151
+ system_prompt: str,
152
+ user_prompt: str,
153
+ model: str = MODEL_FAST,
154
+ max_tokens: int = 1500,
155
+ *,
156
+ script: str | None = None,
157
+ json_mode: bool = False,
158
+ ) -> str:
159
+ """Call OpenRouter chat completions API. Returns assistant message text.
160
+
161
+ When *script* is provided, model and max_tokens are overridden from
162
+ koog-config.json (external config the bot cannot modify).
163
+
164
+ When *json_mode* is True and the resolved model starts with ``openai/``,
165
+ ``response_format: {"type": "json_object"}`` is added to the request body.
166
+ """
167
+ timeout_s = 60
168
+ if script:
169
+ cfg = _load_config()
170
+ script_cfg = cfg.get("scripts", {}).get(script, cfg.get("defaults", {}))
171
+ model = _resolve_model(script_cfg.get("model", "fast"))
172
+ max_tokens = script_cfg.get("maxTokens", max_tokens)
173
+ timeout_s = script_cfg.get("timeout", cfg.get("defaults", {}).get("timeout", 60))
174
+
175
+ api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OPENROUTER_API_KEY_REFLECTION")
176
+ if not api_key:
177
+ raise RuntimeError("OPENROUTER_API_KEY or OPENROUTER_API_KEY_REFLECTION env var is not set")
178
+
179
+ body: dict = {
180
+ "model": model,
181
+ "max_tokens": max_tokens,
182
+ "messages": [
183
+ {"role": "system", "content": system_prompt},
184
+ {"role": "user", "content": user_prompt},
185
+ ],
186
+ }
187
+ if json_mode and model.startswith("openai/"):
188
+ body["response_format"] = {"type": "json_object"}
189
+
190
+ try:
191
+ resp = requests.post(
192
+ OPENROUTER_URL,
193
+ headers={
194
+ "Authorization": f"Bearer {api_key}",
195
+ "Content-Type": "application/json",
196
+ },
197
+ json=body,
198
+ timeout=timeout_s,
199
+ )
200
+ resp.raise_for_status()
201
+ data = resp.json()
202
+ except requests.exceptions.RequestException as e:
203
+ raise LLMError(f"LLM call failed ({type(e).__name__}): {e}") from e
204
+
205
+ # Log token usage to stderr for cost tracking
206
+ usage = data.get("usage", {})
207
+ if usage:
208
+ print(
209
+ f"[tokens] model={model} prompt={usage.get('prompt_tokens', '?')} "
210
+ f"completion={usage.get('completion_tokens', '?')} "
211
+ f"total={usage.get('total_tokens', '?')}",
212
+ file=sys.stderr,
213
+ )
214
+
215
+ content = data["choices"][0]["message"]["content"]
216
+ if not content:
217
+ raise LLMError(f"LLM returned empty response (model={model})")
218
+ return content
219
+
220
+
221
+ def call_llm_with_fallback(
222
+ system_prompt: str,
223
+ user_prompt: str,
224
+ *,
225
+ script: str | None = None,
226
+ json_mode: bool = False,
227
+ retries: int = 1,
228
+ ) -> str:
229
+ """call_llm with automatic retry on failure (same model).
230
+
231
+ Tries the configured model. On LLMError (timeout, HTTP error, empty
232
+ response), retries up to *retries* times with the same model.
233
+ """
234
+ last_err: LLMError | None = None
235
+ for attempt in range(1 + retries):
236
+ try:
237
+ return call_llm(system_prompt, user_prompt, script=script, json_mode=json_mode)
238
+ except LLMError as e:
239
+ last_err = e
240
+ if attempt < retries:
241
+ wait = 2 ** attempt # 1s, 2s, 4s...
242
+ print(f"[retry] attempt {attempt + 1} failed: {e} — retrying in {wait}s",
243
+ file=sys.stderr)
244
+ time.sleep(wait)
245
+ else:
246
+ print(f"[retry] all {1 + retries} attempts failed: {e}", file=sys.stderr)
247
+ raise last_err
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Memory file readers
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def read_playbook(memory_dir: str) -> str:
255
+ """Read sinain-playbook.md, return empty string if missing."""
256
+ p = Path(memory_dir) / "sinain-playbook.md"
257
+ return p.read_text(encoding="utf-8") if p.exists() else ""
258
+
259
+
260
+ def read_effective_playbook(memory_dir: str) -> str:
261
+ """Read the merged effective playbook, falling back to the base playbook.
262
+
263
+ The effective playbook (sinain-playbook-effective.md) is generated by the
264
+ plugin at each agent start by merging active module patterns with the base
265
+ playbook. If it doesn't exist yet, this transparently falls back to the
266
+ base sinain-playbook.md so scripts work before the module system is active.
267
+ """
268
+ effective = Path(memory_dir) / "sinain-playbook-effective.md"
269
+ if effective.exists():
270
+ return effective.read_text(encoding="utf-8")
271
+ return read_playbook(memory_dir)
272
+
273
+
274
+ def parse_module_stack(playbook_text: str) -> list[dict]:
275
+ """Extract module stack from ``<!-- module-stack: id(prio), ... -->`` comment.
276
+
277
+ Returns a list of ``{"id": str, "priority": int}`` dicts sorted by priority
278
+ descending (highest first), or an empty list if the comment is absent.
279
+ """
280
+ m = re.search(r"<!--\s*module-stack:\s*([^>]+?)\s*-->", playbook_text)
281
+ if not m:
282
+ return []
283
+ raw = m.group(1)
284
+ stack: list[dict] = []
285
+ for token in raw.split(","):
286
+ token = token.strip()
287
+ if not token:
288
+ continue
289
+ # Parse "module-id(priority)" format
290
+ paren = re.match(r"^(.+?)\((\d+)\)$", token)
291
+ if paren:
292
+ stack.append({"id": paren.group(1).strip(), "priority": int(paren.group(2))})
293
+ else:
294
+ stack.append({"id": token, "priority": 0})
295
+ stack.sort(key=lambda e: e["priority"], reverse=True)
296
+ return stack
297
+
298
+
299
+ def _read_jsonl(path: Path) -> list[dict]:
300
+ """Read a JSONL file into a list of dicts, skipping bad lines."""
301
+ if not path.exists():
302
+ return []
303
+ entries = []
304
+ for line in path.read_text(encoding="utf-8").splitlines():
305
+ line = line.strip()
306
+ if not line:
307
+ continue
308
+ try:
309
+ entries.append(json.loads(line))
310
+ except json.JSONDecodeError:
311
+ continue
312
+ return entries
313
+
314
+
315
+ def read_recent_logs(memory_dir: str, days: int = 7) -> list[dict]:
316
+ """Read playbook-logs from the last N days, newest first."""
317
+ log_dir = Path(memory_dir) / "playbook-logs"
318
+ if not log_dir.is_dir():
319
+ return []
320
+
321
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
322
+ entries: list[dict] = []
323
+
324
+ for jsonl_file in sorted(log_dir.glob("*.jsonl"), reverse=True):
325
+ # Filename is YYYY-MM-DD.jsonl
326
+ try:
327
+ file_date = datetime.strptime(jsonl_file.stem, "%Y-%m-%d").replace(
328
+ tzinfo=timezone.utc
329
+ )
330
+ except ValueError:
331
+ continue
332
+ if file_date < cutoff:
333
+ break
334
+ entries.extend(_read_jsonl(jsonl_file))
335
+
336
+ # Sort by timestamp descending
337
+ entries.sort(key=lambda e: e.get("ts", ""), reverse=True)
338
+ return entries
339
+
340
+
341
+ def read_today_log(memory_dir: str) -> list[dict]:
342
+ """Read today's playbook-log entries."""
343
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
344
+ log_file = Path(memory_dir) / "playbook-logs" / f"{today}.jsonl"
345
+ return _read_jsonl(log_file)
346
+
347
+
348
+ def list_daily_memory_files(memory_dir: str) -> list[str]:
349
+ """List YYYY-MM-DD.md files in memory/, sorted newest first."""
350
+ pattern = str(Path(memory_dir) / "????-??-??.md")
351
+ files = sorted(glob(pattern), reverse=True)
352
+ return files
353
+
354
+
355
+ def parse_mining_index(playbook_text: str) -> list[str]:
356
+ """Extract mined dates from <!-- mining-index: ... --> comment."""
357
+ m = re.search(r"<!--\s*mining-index:\s*([^>]+?)\s*-->", playbook_text)
358
+ if not m:
359
+ return []
360
+ return [d.strip() for d in m.group(1).split(",") if d.strip()]
361
+
362
+
363
+ def parse_effectiveness(playbook_text: str) -> dict | None:
364
+ """Extract effectiveness metrics from <!-- effectiveness: ... --> comment.
365
+
366
+ Returns dict with keys: outputs, positive, negative, neutral, rate, updated.
367
+ """
368
+ m = re.search(r"<!--\s*effectiveness:\s*([^>]+?)\s*-->", playbook_text)
369
+ if not m:
370
+ return None
371
+ raw = m.group(1)
372
+ result = {}
373
+ for pair in raw.split(","):
374
+ pair = pair.strip()
375
+ if "=" not in pair:
376
+ continue
377
+ key, val = pair.split("=", 1)
378
+ key = key.strip()
379
+ val = val.strip()
380
+ # Try numeric conversion
381
+ try:
382
+ result[key] = int(val)
383
+ except ValueError:
384
+ try:
385
+ result[key] = float(val)
386
+ except ValueError:
387
+ result[key] = val
388
+ return result if result else None
389
+
390
+
391
+ def read_file_safe(path: str) -> str:
392
+ """Read a file, return empty string if missing."""
393
+ p = Path(path)
394
+ return p.read_text(encoding="utf-8") if p.exists() else ""
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Output
399
+ # ---------------------------------------------------------------------------
400
+
401
+ def output_json(data: dict) -> None:
402
+ """Print compact JSON to stdout (for main agent to capture)."""
403
+ print(json.dumps(data, ensure_ascii=False))