@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
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))
|