subcortex 0.3.0__py3-none-any.whl
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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tiny HTTP client for hook processes talking to the local daemon.
|
|
2
|
+
|
|
3
|
+
Stdlib only (hook processes start cold on every event, so imports stay light).
|
|
4
|
+
Every call returns ``None`` on any failure — connection refused, timeout, bad
|
|
5
|
+
JSON, ``success: false`` — so callers can fail open.
|
|
6
|
+
|
|
7
|
+
When the daemon is down (connection refused) and ``hooks.autostart_daemon`` is
|
|
8
|
+
on, the client starts it in the background — at most once a minute, never
|
|
9
|
+
waiting for it — so hooks come back to life after a reboot. The current hook
|
|
10
|
+
still fails open; the daemon's single-instance lock prevents duplicates.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from . import localhttp
|
|
20
|
+
|
|
21
|
+
AUTOSTART_INTERVAL_S = 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DaemonClient:
|
|
25
|
+
def __init__(self, cfg: Dict[str, Any], timeout: Optional[float] = None) -> None:
|
|
26
|
+
self.port = int(cfg["port"])
|
|
27
|
+
hooks = cfg.get("hooks") or {}
|
|
28
|
+
self.timeout = float(timeout if timeout is not None else hooks.get("http_timeout_s", 3.0))
|
|
29
|
+
self.autostart = bool(hooks.get("autostart_daemon", True))
|
|
30
|
+
|
|
31
|
+
def post(self, path: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
32
|
+
try:
|
|
33
|
+
status, body = localhttp.request(self.port, "POST", path, payload, self.timeout)
|
|
34
|
+
except ConnectionRefusedError:
|
|
35
|
+
self._autostart()
|
|
36
|
+
return None
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
if status != 200 or not isinstance(body, dict) or body.get("success") is False:
|
|
40
|
+
return None
|
|
41
|
+
return body
|
|
42
|
+
|
|
43
|
+
def _autostart(self) -> None:
|
|
44
|
+
if not self.autostart:
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
from .config import data_dir, log_path
|
|
48
|
+
from .provision import daemon_argv
|
|
49
|
+
|
|
50
|
+
stamp = data_dir() / "autostart.stamp"
|
|
51
|
+
if stamp.exists() and time.time() - stamp.stat().st_mtime < AUTOSTART_INTERVAL_S:
|
|
52
|
+
return
|
|
53
|
+
stamp.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
54
|
+
stamp.touch()
|
|
55
|
+
import subprocess
|
|
56
|
+
|
|
57
|
+
with open(log_path(), "ab") as log:
|
|
58
|
+
# cwd: never the user's project (see provision.daemon_argv).
|
|
59
|
+
subprocess.Popen(daemon_argv(), cwd=str(data_dir()),
|
|
60
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
|
|
61
|
+
start_new_session=True, close_fds=True, env=dict(os.environ))
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def _verdict(self, path: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
66
|
+
body = self.post(path, payload)
|
|
67
|
+
verdict = body.get("verdict") if body else None
|
|
68
|
+
return verdict if isinstance(verdict, dict) else None
|
|
69
|
+
|
|
70
|
+
def classify(self, prompt: str) -> Optional[Dict[str, Any]]:
|
|
71
|
+
return self._verdict("/verdict/prompt", {"prompt": prompt})
|
|
72
|
+
|
|
73
|
+
def judge(self, output: str, context: str, task: str = "") -> Optional[Dict[str, Any]]:
|
|
74
|
+
payload = {"output": output, "context": context}
|
|
75
|
+
if task:
|
|
76
|
+
payload["task"] = task
|
|
77
|
+
return self._verdict("/verdict/output", payload)
|
subcortex/config.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Configuration for subcortex.
|
|
2
|
+
|
|
3
|
+
Config file: ``~/.config/subcortex/config.json`` (override the path with
|
|
4
|
+
``SUBCORTEX_CONFIG``). Missing file = defaults. ``SUBCORTEX_*`` env vars
|
|
5
|
+
override individual keys.
|
|
6
|
+
|
|
7
|
+
Keys::
|
|
8
|
+
|
|
9
|
+
backend "laya" | "jev" (default "laya")
|
|
10
|
+
port daemon port (default 7707)
|
|
11
|
+
model laya model alias (default "multilingual")
|
|
12
|
+
thresholds prompt_simple_confidence (null = calibrated per backend)
|
|
13
|
+
output_needed_threshold (null = calibrated per backend)
|
|
14
|
+
min_output_chars (6000)
|
|
15
|
+
features prompt_hint, trim_output, compaction_snapshot (all true)
|
|
16
|
+
hooks autostart_daemon (true) a hook that finds the daemon down starts it
|
|
17
|
+
budget_s (4.0) hard wall-clock cap for one hook invocation
|
|
18
|
+
http_timeout_s (3.0), head_chars (1000), tail_chars (500),
|
|
19
|
+
snapshot_messages (5), snapshot_chars (500)
|
|
20
|
+
jev base_url, endpoint_path, api_key_env, model, timeout
|
|
21
|
+
|
|
22
|
+
Runtime state lives in ``~/.local/share/subcortex`` (``SUBCORTEX_DATA_DIR``
|
|
23
|
+
overrides; resolved at call time via ``data_dir()``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import copy
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, Optional
|
|
33
|
+
|
|
34
|
+
CONFIG_PATH = Path.home() / ".config" / "subcortex" / "config.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
38
|
+
"backend": "laya",
|
|
39
|
+
"port": 7707,
|
|
40
|
+
"model": "multilingual",
|
|
41
|
+
"thresholds": {
|
|
42
|
+
# null = the calibrated rule of the active backend (verdicts.py); a
|
|
43
|
+
# number overrides that rule's primary threshold.
|
|
44
|
+
"prompt_simple_confidence": None,
|
|
45
|
+
"output_needed_threshold": None,
|
|
46
|
+
"min_output_chars": 6000,
|
|
47
|
+
},
|
|
48
|
+
"features": {
|
|
49
|
+
"prompt_hint": True,
|
|
50
|
+
"trim_output": True,
|
|
51
|
+
"compaction_snapshot": True,
|
|
52
|
+
},
|
|
53
|
+
"hooks": {
|
|
54
|
+
"autostart_daemon": True,
|
|
55
|
+
"budget_s": 4.0,
|
|
56
|
+
"http_timeout_s": 3.0,
|
|
57
|
+
"head_chars": 1000,
|
|
58
|
+
"tail_chars": 500,
|
|
59
|
+
"snapshot_messages": 5,
|
|
60
|
+
"snapshot_chars": 500,
|
|
61
|
+
},
|
|
62
|
+
"jev": {
|
|
63
|
+
"base_url": "https://api.typesafe.ai",
|
|
64
|
+
"endpoint_path": "/v1/systemone",
|
|
65
|
+
"api_key_env": "TYPESAFE_API_KEY",
|
|
66
|
+
"model": "jev-latest",
|
|
67
|
+
"timeout": 2.5,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# env var -> (section or None, key, coerce)
|
|
72
|
+
_ENV_OVERRIDES = {
|
|
73
|
+
"SUBCORTEX_BACKEND": (None, "backend", str),
|
|
74
|
+
"SUBCORTEX_PORT": (None, "port", int),
|
|
75
|
+
"SUBCORTEX_MODEL": (None, "model", str),
|
|
76
|
+
"SUBCORTEX_JEV_BASE_URL": ("jev", "base_url", str),
|
|
77
|
+
"SUBCORTEX_JEV_ENDPOINT_PATH": ("jev", "endpoint_path", str),
|
|
78
|
+
"SUBCORTEX_JEV_API_KEY_ENV": ("jev", "api_key_env", str),
|
|
79
|
+
"SUBCORTEX_JEV_MODEL": ("jev", "model", str),
|
|
80
|
+
"SUBCORTEX_JEV_TIMEOUT": ("jev", "timeout", float),
|
|
81
|
+
"SUBCORTEX_HOOK_BUDGET": ("hooks", "budget_s", float),
|
|
82
|
+
"SUBCORTEX_AUTOSTART": ("hooks", "autostart_daemon", "bool"),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _bool(raw: str) -> bool:
|
|
87
|
+
return raw.strip().lower() not in ("0", "false", "no", "off", "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def data_dir() -> Path:
|
|
91
|
+
"""Runtime state dir, resolved per call so tests can redirect HOME."""
|
|
92
|
+
override = os.environ.get("SUBCORTEX_DATA_DIR", "").strip()
|
|
93
|
+
return Path(override) if override else Path.home() / ".local" / "share" / "subcortex"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def venv_dir() -> Path:
|
|
97
|
+
"""The dedicated backend environment (laya), under the data dir."""
|
|
98
|
+
return data_dir() / "venv"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def venv_python() -> Path:
|
|
102
|
+
return venv_dir() / "bin" / "python"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def venv_pip() -> Path:
|
|
106
|
+
return venv_dir() / "bin" / "pip"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lock_path() -> Path:
|
|
110
|
+
return data_dir() / "daemon.lock"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pid_path() -> Path:
|
|
114
|
+
return data_dir() / "daemon.pid"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def log_path() -> Path:
|
|
118
|
+
return data_dir() / "daemon.log"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def config_path() -> Path:
|
|
122
|
+
return Path(os.environ.get("SUBCORTEX_CONFIG", "") or CONFIG_PATH)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
126
|
+
for key, value in override.items():
|
|
127
|
+
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
128
|
+
_merge(base[key], value)
|
|
129
|
+
else:
|
|
130
|
+
base[key] = value
|
|
131
|
+
return base
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
|
135
|
+
"""Load config: defaults < config.json < SUBCORTEX_* env. Never raises."""
|
|
136
|
+
cfg = copy.deepcopy(DEFAULT_CONFIG)
|
|
137
|
+
file_path = Path(path) if path else config_path()
|
|
138
|
+
try:
|
|
139
|
+
if file_path.is_file():
|
|
140
|
+
data = json.loads(file_path.read_text())
|
|
141
|
+
if isinstance(data, dict):
|
|
142
|
+
_merge(cfg, data)
|
|
143
|
+
except (OSError, ValueError):
|
|
144
|
+
pass # corrupt/unreadable config degrades to defaults
|
|
145
|
+
for env_name, (section, key, coerce) in _ENV_OVERRIDES.items():
|
|
146
|
+
raw = os.environ.get(env_name, "").strip()
|
|
147
|
+
if not raw:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
value = _bool(raw) if coerce == "bool" else coerce(raw)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
continue
|
|
153
|
+
target = cfg[section] if section else cfg
|
|
154
|
+
target[key] = value
|
|
155
|
+
return cfg
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def unset_config(dotted: str) -> bool:
|
|
159
|
+
"""Remove one key (``section.key``) from config.json, reverting it to its default."""
|
|
160
|
+
path = config_path()
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads(path.read_text())
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
return False
|
|
165
|
+
parts = dotted.split(".")
|
|
166
|
+
node = data
|
|
167
|
+
for part in parts[:-1]:
|
|
168
|
+
node = node.get(part) if isinstance(node, dict) else None
|
|
169
|
+
if not isinstance(node, dict):
|
|
170
|
+
return False
|
|
171
|
+
if parts[-1] not in node:
|
|
172
|
+
return False
|
|
173
|
+
del node[parts[-1]]
|
|
174
|
+
_atomic_write(path, json.dumps(data, indent=2) + "\n")
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def save_config(updates: Dict[str, Any]) -> Path:
|
|
179
|
+
"""Merge *updates* into config.json (preserving other keys) and return the path."""
|
|
180
|
+
path = config_path()
|
|
181
|
+
data: Dict[str, Any] = {}
|
|
182
|
+
try:
|
|
183
|
+
if path.is_file():
|
|
184
|
+
existing = json.loads(path.read_text())
|
|
185
|
+
if isinstance(existing, dict):
|
|
186
|
+
data = existing
|
|
187
|
+
except (OSError, ValueError):
|
|
188
|
+
pass
|
|
189
|
+
_merge(data, updates)
|
|
190
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
_atomic_write(path, json.dumps(data, indent=2) + "\n")
|
|
192
|
+
return path
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _atomic_write(path: Path, text: str, mode: int = 0o644) -> None:
|
|
196
|
+
"""Hooks read config.json concurrently: a torn file would silently mean
|
|
197
|
+
defaults (re-enabling what the user switched off), so replace it whole."""
|
|
198
|
+
import tempfile # only writers pay for it; hooks only read
|
|
199
|
+
|
|
200
|
+
if path.is_symlink(): # dotfiles: change the file, keep the link
|
|
201
|
+
path = Path(os.path.realpath(path))
|
|
202
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
203
|
+
try:
|
|
204
|
+
os.fchmod(fd, mode)
|
|
205
|
+
with os.fdopen(fd, "w") as fh:
|
|
206
|
+
fh.write(text)
|
|
207
|
+
os.replace(tmp, path)
|
|
208
|
+
except BaseException:
|
|
209
|
+
try:
|
|
210
|
+
os.unlink(tmp)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
raise
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# -- secrets ---------------------------------------------------------------------------------
|
|
217
|
+
#
|
|
218
|
+
# API keys may live in the environment (preferred) or, for daemons started
|
|
219
|
+
# outside the user's shell (by a TUI hook or at login), in secrets.json next to
|
|
220
|
+
# the config file — created with mode 0600 and never printed.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def secrets_path() -> Path:
|
|
224
|
+
return config_path().parent / "secrets.json"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _read_secrets() -> Dict[str, str]:
|
|
228
|
+
try:
|
|
229
|
+
data = json.loads(secrets_path().read_text())
|
|
230
|
+
except (OSError, ValueError):
|
|
231
|
+
return {}
|
|
232
|
+
return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_secret(name: str) -> Optional[str]:
|
|
236
|
+
"""``$name`` if set, else the value stored in secrets.json, else None."""
|
|
237
|
+
value = os.environ.get(name, "").strip()
|
|
238
|
+
return value or (_read_secrets().get(name) or "").strip() or None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _write_secrets(data: Dict[str, str]) -> Path:
|
|
242
|
+
path = secrets_path()
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
_atomic_write(path, json.dumps(data, indent=2) + "\n", mode=0o600)
|
|
245
|
+
return path
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def save_secret(name: str, value: str) -> Path:
|
|
249
|
+
data = _read_secrets()
|
|
250
|
+
data[name] = value
|
|
251
|
+
return _write_secrets(data)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def delete_secret(name: str) -> bool:
|
|
255
|
+
data = _read_secrets()
|
|
256
|
+
if name not in data:
|
|
257
|
+
return False
|
|
258
|
+
del data[name]
|
|
259
|
+
if data:
|
|
260
|
+
_write_secrets(data)
|
|
261
|
+
else:
|
|
262
|
+
secrets_path().unlink()
|
|
263
|
+
return True
|