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.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. 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