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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""OpenHands CLI: ``~/.openhands/hooks.json`` (+ optional ``mcp.json``).
|
|
2
|
+
|
|
3
|
+
OpenHands loads exactly one hooks file — a project ``.openhands/hooks.json``
|
|
4
|
+
replaces the user one entirely — and a file with an unknown event key or both
|
|
5
|
+
key styles (``user_prompt_submit`` and ``UserPromptSubmit``) breaks conversation
|
|
6
|
+
setup. So we reuse whatever style the file already has and add nothing else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from .base import Installer, Target, add_grouped, json_target, mcp_json_target, remove_grouped
|
|
16
|
+
|
|
17
|
+
SNAKE, PASCAL = "user_prompt_submit", "UserPromptSubmit"
|
|
18
|
+
_PASCAL_KEYS = {"PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionStart", "SessionEnd"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _table(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
22
|
+
wrapper = data.get("hooks")
|
|
23
|
+
return wrapper if isinstance(wrapper, dict) else data
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def persistence_dir() -> Path:
|
|
27
|
+
override = os.environ.get("OPENHANDS_PERSISTENCE_DIR", "").strip()
|
|
28
|
+
return Path(override) if override else Path.home() / ".openhands"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenHandsInstaller(Installer):
|
|
32
|
+
name = "openhands"
|
|
33
|
+
display_name = "OpenHands CLI"
|
|
34
|
+
seam = "hooks"
|
|
35
|
+
binaries = ("openhands",)
|
|
36
|
+
docs = "https://docs.openhands.dev/openhands/usage/customization/hooks"
|
|
37
|
+
min_version = "1.12.0"
|
|
38
|
+
supports_mcp = True
|
|
39
|
+
post_install = "applies to new OpenHands conversations"
|
|
40
|
+
|
|
41
|
+
def _entry(self) -> Dict[str, Any]:
|
|
42
|
+
return {"type": "command", "command": self.command("UserPromptSubmit"), "timeout": 10}
|
|
43
|
+
|
|
44
|
+
def targets(self) -> List[Target]:
|
|
45
|
+
def add(data: Dict[str, Any]) -> None:
|
|
46
|
+
table = _table(data)
|
|
47
|
+
key = PASCAL if any(k in _PASCAL_KEYS for k in table) else SNAKE
|
|
48
|
+
add_grouped(table, key, "*", self._entry())
|
|
49
|
+
|
|
50
|
+
def remove(data: Dict[str, Any]) -> None:
|
|
51
|
+
remove_grouped(_table(data))
|
|
52
|
+
|
|
53
|
+
def installed(data: Dict[str, Any]) -> bool:
|
|
54
|
+
probe = {k: v for k, v in _table(data).items() if isinstance(v, list)}
|
|
55
|
+
return bool(remove_grouped(probe))
|
|
56
|
+
|
|
57
|
+
targets = [json_target(Path.home() / ".openhands" / "hooks.json", add, remove, installed)]
|
|
58
|
+
if self.mcp:
|
|
59
|
+
targets.append(mcp_json_target(
|
|
60
|
+
persistence_dir() / "mcp.json", self.mcp_command(),
|
|
61
|
+
extra={"transport": "stdio", "enabled": True}))
|
|
62
|
+
return targets
|
|
63
|
+
|
|
64
|
+
def warnings(self) -> List[str]:
|
|
65
|
+
try:
|
|
66
|
+
project = Path.cwd() / ".openhands" / "hooks.json"
|
|
67
|
+
except OSError: # cwd was deleted
|
|
68
|
+
return []
|
|
69
|
+
if project.is_file():
|
|
70
|
+
return [f"{project} exists and replaces the user-level hooks file inside this "
|
|
71
|
+
"project; subcortex will not run here unless you add it there too"]
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def hook_events(self) -> List[str]:
|
|
75
|
+
return ["UserPromptSubmit"]
|
|
76
|
+
|
|
77
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
78
|
+
return {"event_type": "UserPromptSubmit", "tool_name": None, "tool_input": None,
|
|
79
|
+
"tool_response": None, "message": "what does ls -la do?",
|
|
80
|
+
"session_id": "4f1c2a9e-8b7d-4e21-9c3a-0d5e6f7a8b9c",
|
|
81
|
+
"working_dir": "/tmp", "metadata": {}}
|
|
82
|
+
|
|
83
|
+
def expects_output(self, event: str) -> bool:
|
|
84
|
+
return True
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Pi and Cline CLI: one TypeScript plugin file each (+ optional MCP for Cline).
|
|
2
|
+
|
|
3
|
+
Both load every file in their plugin directory, so the plugin is a single
|
|
4
|
+
self-contained file with type-only imports. Pi exits on a plugin load error
|
|
5
|
+
and Cline fails the run on a hook error or a hook slower than 3 s — the
|
|
6
|
+
plugins catch everything and race every hook against a deadline, and
|
|
7
|
+
tests/test_plugins.py runs them under Bun against a live daemon.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
from .base import Installer, Target, rendered_plugin, mcp_json_target, plugin_file_target
|
|
17
|
+
from .mcp_only import cline_dir, cline_mcp_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PiInstaller(Installer):
|
|
21
|
+
name = "pi"
|
|
22
|
+
display_name = "Pi"
|
|
23
|
+
seam = "plugin"
|
|
24
|
+
binaries = ("pi",)
|
|
25
|
+
docs = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md"
|
|
26
|
+
min_version = "0.87.0"
|
|
27
|
+
post_install = "new pi sessions load the extension"
|
|
28
|
+
|
|
29
|
+
def extensions_dir(self) -> Path:
|
|
30
|
+
override = os.environ.get("PI_CODING_AGENT_DIR", "").strip()
|
|
31
|
+
return (Path(override) if override else Path.home() / ".pi" / "agent") / "extensions"
|
|
32
|
+
|
|
33
|
+
def targets(self) -> List[Target]:
|
|
34
|
+
content = rendered_plugin("pi")
|
|
35
|
+
return [plugin_file_target(self.extensions_dir() / "subcortex.ts", content)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ClineInstaller(Installer):
|
|
39
|
+
name = "cline"
|
|
40
|
+
display_name = "Cline CLI"
|
|
41
|
+
seam = "plugin"
|
|
42
|
+
binaries = ("cline",)
|
|
43
|
+
docs = "https://docs.cline.bot/sdk/plugins"
|
|
44
|
+
min_version = "3.0.62"
|
|
45
|
+
supports_mcp = True
|
|
46
|
+
post_install = "new cline runs load the plugin"
|
|
47
|
+
|
|
48
|
+
def targets(self) -> List[Target]:
|
|
49
|
+
content = rendered_plugin("cline")
|
|
50
|
+
targets = [plugin_file_target(cline_dir() / "plugins" / "subcortex.ts", content)]
|
|
51
|
+
if self.mcp:
|
|
52
|
+
targets.append(mcp_json_target(cline_mcp_path(), self.mcp_command(), extra={"type": "stdio"}))
|
|
53
|
+
return targets
|
subcortex/ledger.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""What subcortex did, for ``subcortex stats``: one JSON line per event.
|
|
2
|
+
|
|
3
|
+
Hooks and the daemon append counts only — never prompts or output — to a
|
|
4
|
+
private (0600) file in the data dir. Appends of one short line are atomic, so
|
|
5
|
+
concurrent hook processes need no lock. The file rotates at ``MAX_BYTES``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Iterator, Optional
|
|
14
|
+
|
|
15
|
+
from .config import data_dir
|
|
16
|
+
|
|
17
|
+
MAX_BYTES = 5_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path():
|
|
21
|
+
return data_dir() / "ledger.jsonl"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def record(kind: str, tui: str = "", **fields: Any) -> None:
|
|
25
|
+
"""Append one event (``hint``, ``trim``, ``restore``, ``jev``). Never raises."""
|
|
26
|
+
try:
|
|
27
|
+
path = _path()
|
|
28
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
29
|
+
try:
|
|
30
|
+
if path.stat().st_size > MAX_BYTES:
|
|
31
|
+
os.replace(path, path.with_name("ledger.1.jsonl"))
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
pass
|
|
34
|
+
line = json.dumps({"t": int(time.time()), "k": kind, "tui": tui, **fields},
|
|
35
|
+
separators=(",", ":")) + "\n"
|
|
36
|
+
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
37
|
+
try:
|
|
38
|
+
os.write(fd, line.encode("utf-8"))
|
|
39
|
+
finally:
|
|
40
|
+
os.close(fd)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _events() -> Iterator[Dict[str, Any]]:
|
|
46
|
+
for name in ("ledger.1.jsonl", "ledger.jsonl"):
|
|
47
|
+
try:
|
|
48
|
+
with open(data_dir() / name, encoding="utf-8") as fh:
|
|
49
|
+
for line in fh:
|
|
50
|
+
try:
|
|
51
|
+
event = json.loads(line)
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
if isinstance(event, dict):
|
|
55
|
+
yield event
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def summary(since: Optional[float] = None) -> Dict[str, Any]:
|
|
61
|
+
"""Totals overall and per TUI, optionally only for events after ``since``."""
|
|
62
|
+
total: Dict[str, Any] = {"hints": 0, "trims": 0, "chars_removed": 0, "restores": 0,
|
|
63
|
+
"jev_calls": 0, "jev_input_tokens": 0, "jev_cost_usd": 0.0}
|
|
64
|
+
per_tui: Dict[str, Dict[str, int]] = {}
|
|
65
|
+
first = None
|
|
66
|
+
for event in _events():
|
|
67
|
+
t = event.get("t") or 0
|
|
68
|
+
if since is not None and t < since:
|
|
69
|
+
continue
|
|
70
|
+
first = t if first is None else min(first, t)
|
|
71
|
+
kind, tui = event.get("k"), str(event.get("tui") or "?")
|
|
72
|
+
row = per_tui.setdefault(tui, {"hints": 0, "trims": 0, "chars_removed": 0, "restores": 0})
|
|
73
|
+
if kind == "hint":
|
|
74
|
+
total["hints"] += 1
|
|
75
|
+
row["hints"] += 1
|
|
76
|
+
elif kind == "trim":
|
|
77
|
+
removed = max(0, int(event.get("before", 0)) - int(event.get("after", 0)))
|
|
78
|
+
total["trims"] += 1
|
|
79
|
+
total["chars_removed"] += removed
|
|
80
|
+
row["trims"] += 1
|
|
81
|
+
row["chars_removed"] += removed
|
|
82
|
+
elif kind == "restore":
|
|
83
|
+
total["restores"] += 1
|
|
84
|
+
row["restores"] += 1
|
|
85
|
+
elif kind == "jev":
|
|
86
|
+
total["jev_calls"] += 1
|
|
87
|
+
total["jev_input_tokens"] += int(event.get("tokens", 0))
|
|
88
|
+
total["jev_cost_usd"] += float(event.get("usd", 0.0))
|
|
89
|
+
total["jev_cost_usd"] = round(total["jev_cost_usd"], 6)
|
|
90
|
+
per_tui = {k: v for k, v in per_tui.items() if k != "?" or any(v.values())}
|
|
91
|
+
return {"since": first, "total": total,
|
|
92
|
+
"per_tui": {k: v for k, v in per_tui.items() if any(v.values())}}
|
subcortex/localhttp.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""HTTP to the local daemon over a direct socket to 127.0.0.1, and nothing else.
|
|
2
|
+
|
|
3
|
+
Not urllib: urllib honors HTTP_PROXY and the system proxy settings, so on a
|
|
4
|
+
machine with a proxy configured, requests for 127.0.0.1 (carrying prompts and
|
|
5
|
+
tool output) went to the proxy, or failed. It is also the slowest import in a
|
|
6
|
+
hook process (~15 ms). The daemon answers HTTP/1.0: one request per connection,
|
|
7
|
+
closed after the reply, which is all this client needs to handle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
MAX_RESPONSE_BYTES = 8_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalHTTPError(OSError):
|
|
21
|
+
"""The daemon answered something that is not a well-formed HTTP reply."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request(port: int, method: str, path: str, payload: Optional[Dict[str, Any]] = None,
|
|
25
|
+
timeout: float = 3.0) -> Tuple[int, Any]:
|
|
26
|
+
"""``(status, parsed JSON body)``. Raises OSError (incl. ConnectionRefusedError,
|
|
27
|
+
TimeoutError) or ValueError; the whole exchange is bounded by ``timeout``."""
|
|
28
|
+
from .auth import HEADER, read_token
|
|
29
|
+
|
|
30
|
+
body = b"" if payload is None else json.dumps(payload).encode("utf-8")
|
|
31
|
+
head = (f"{method} {path} HTTP/1.0\r\nHost: 127.0.0.1:{int(port)}\r\n"
|
|
32
|
+
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
|
|
33
|
+
f"{HEADER}: {read_token()}\r\nX-Subcortex-Timeout-Ms: {int(timeout * 1000)}\r\n\r\n")
|
|
34
|
+
deadline = time.monotonic() + timeout
|
|
35
|
+
chunks, size = [], 0
|
|
36
|
+
# One deadline for the whole exchange: connect, send and every read share it.
|
|
37
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
38
|
+
sock.settimeout(timeout)
|
|
39
|
+
sock.connect(("127.0.0.1", int(port)))
|
|
40
|
+
sock.settimeout(max(0.01, deadline - time.monotonic()))
|
|
41
|
+
sock.sendall(head.encode("ascii") + body)
|
|
42
|
+
while True:
|
|
43
|
+
remaining = deadline - time.monotonic()
|
|
44
|
+
if remaining <= 0:
|
|
45
|
+
raise TimeoutError("daemon reply timed out")
|
|
46
|
+
sock.settimeout(remaining)
|
|
47
|
+
chunk = sock.recv(65536)
|
|
48
|
+
if not chunk:
|
|
49
|
+
break
|
|
50
|
+
size += len(chunk)
|
|
51
|
+
if size > MAX_RESPONSE_BYTES:
|
|
52
|
+
raise LocalHTTPError("daemon reply too large")
|
|
53
|
+
chunks.append(chunk)
|
|
54
|
+
raw = b"".join(chunks)
|
|
55
|
+
header, sep, rest = raw.partition(b"\r\n\r\n")
|
|
56
|
+
status_line = header.split(b"\r\n", 1)[0].split(b" ", 2)
|
|
57
|
+
if not sep or len(status_line) < 2 or not status_line[0].startswith(b"HTTP/"):
|
|
58
|
+
raise LocalHTTPError("malformed daemon reply")
|
|
59
|
+
return int(status_line[1]), (json.loads(rest) if rest.strip() else None)
|
subcortex/mcp_server.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Minimal stdio MCP server exposing subcortex decisions as tools.
|
|
2
|
+
|
|
3
|
+
This is the generic seam for any MCP-capable TUI (Gemini CLI, Crush, Amp, Cursor…)
|
|
4
|
+
where we don't ship a native hook adapter: the agent can call these tools on demand.
|
|
5
|
+
Automatic interception (prompt classify, output filtering) lives in the hook
|
|
6
|
+
adapters — MCP tools are model-invoked, so they serve on-demand decisions only.
|
|
7
|
+
|
|
8
|
+
Zero dependencies. Transport per the MCP spec's stdio transport: one JSON-RPC
|
|
9
|
+
message per line (UTF-8, newline-delimited, no embedded newlines) on
|
|
10
|
+
stdin/stdout; nothing but protocol messages is ever written to stdout.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_PROTOCOL_VERSION = "2025-06-18"
|
|
25
|
+
_SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
26
|
+
_SERVER_INFO = {"name": "subcortex", "version": __version__}
|
|
27
|
+
|
|
28
|
+
_TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
"name": "subcortex_decide",
|
|
31
|
+
"description": (
|
|
32
|
+
"Fast typed decision (choice/score/boolean with calibrated probabilities) "
|
|
33
|
+
"from the local subcortex service — routing, triage, gating, moderation. "
|
|
34
|
+
"Milliseconds, no LLM tokens."
|
|
35
|
+
),
|
|
36
|
+
"inputSchema": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"state": {"description": "The situation to judge (text or JSON)."},
|
|
40
|
+
"questions": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"description": "Map of name -> {type: choice|score|noul, instructions, criteria?}",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
"required": ["state", "questions"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "subcortex_classify_prompt",
|
|
50
|
+
"description": "Classify a user prompt as simple or complex (with confidence).",
|
|
51
|
+
"inputSchema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {"prompt": {"type": "string"}},
|
|
54
|
+
"required": ["prompt"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "subcortex_judge_output",
|
|
59
|
+
"description": ("Judge whether a tool/command output is still needed for a request, or "
|
|
60
|
+
"disposable. Without the request the output is always kept."),
|
|
61
|
+
"inputSchema": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"output": {"type": "string"},
|
|
65
|
+
"context": {"type": "string", "description": "What produced the output."},
|
|
66
|
+
"task": {"type": "string", "description": "The user's request the output serves."},
|
|
67
|
+
},
|
|
68
|
+
"required": ["output", "task"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_daemon() -> None:
|
|
75
|
+
"""Start the daemon on first use if it isn't running (best effort; honors
|
|
76
|
+
hooks.autostart_daemon, and waits less than MCP clients' ~10 s timeout)."""
|
|
77
|
+
from . import cli
|
|
78
|
+
from .config import load_config
|
|
79
|
+
|
|
80
|
+
cfg = load_config()
|
|
81
|
+
if not (cfg.get("hooks") or {}).get("autostart_daemon", True):
|
|
82
|
+
return
|
|
83
|
+
if cli._health(cfg) is None:
|
|
84
|
+
cli._spawn_daemon(cfg)
|
|
85
|
+
cli._wait_for_health(cfg, timeout_s=8.0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
89
|
+
from . import localhttp
|
|
90
|
+
from .config import load_config
|
|
91
|
+
|
|
92
|
+
port = int(load_config()["port"])
|
|
93
|
+
try:
|
|
94
|
+
_, reply = localhttp.request(port, "POST", path, body, timeout=30)
|
|
95
|
+
except ConnectionRefusedError:
|
|
96
|
+
_ensure_daemon()
|
|
97
|
+
_, reply = localhttp.request(port, "POST", path, body, timeout=30)
|
|
98
|
+
if not isinstance(reply, dict):
|
|
99
|
+
raise ValueError("daemon reply is not a JSON object")
|
|
100
|
+
return reply
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _call_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
104
|
+
if name == "subcortex_decide":
|
|
105
|
+
return _post("/decide", {"state": arguments.get("state"),
|
|
106
|
+
"questions": arguments.get("questions") or {}})
|
|
107
|
+
if name == "subcortex_classify_prompt":
|
|
108
|
+
return _post("/verdict/prompt", {"prompt": arguments.get("prompt", "")})
|
|
109
|
+
if name == "subcortex_judge_output":
|
|
110
|
+
return _post("/verdict/output", {"output": arguments.get("output", ""),
|
|
111
|
+
"context": arguments.get("context", ""),
|
|
112
|
+
"task": arguments.get("task", "")})
|
|
113
|
+
raise ValueError(f"unknown tool {name!r}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _handle(request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
117
|
+
"""One JSON-RPC request -> response (None for notifications)."""
|
|
118
|
+
method = request.get("method", "")
|
|
119
|
+
req_id = request.get("id")
|
|
120
|
+
|
|
121
|
+
def result(value: Any) -> Dict[str, Any]:
|
|
122
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": value}
|
|
123
|
+
|
|
124
|
+
def error(code: int, message: str) -> Dict[str, Any]:
|
|
125
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
126
|
+
|
|
127
|
+
if method == "initialize":
|
|
128
|
+
requested = (request.get("params") or {}).get("protocolVersion")
|
|
129
|
+
return result({
|
|
130
|
+
"protocolVersion": requested if requested in _SUPPORTED_VERSIONS else _PROTOCOL_VERSION,
|
|
131
|
+
"capabilities": {"tools": {}},
|
|
132
|
+
"serverInfo": _SERVER_INFO,
|
|
133
|
+
})
|
|
134
|
+
if method in ("notifications/initialized", "notifications/cancelled"):
|
|
135
|
+
return None
|
|
136
|
+
if method == "ping":
|
|
137
|
+
return result({})
|
|
138
|
+
if method == "tools/list":
|
|
139
|
+
return result({"tools": _TOOLS})
|
|
140
|
+
if method == "tools/call":
|
|
141
|
+
params = request.get("params") or {}
|
|
142
|
+
try:
|
|
143
|
+
outcome = _call_tool(params.get("name", ""), params.get("arguments") or {})
|
|
144
|
+
return result({"content": [{"type": "text", "text": json.dumps(outcome)}]})
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
return result({"content": [{"type": "text", "text": f"subcortex error: {exc}"}],
|
|
147
|
+
"isError": True})
|
|
148
|
+
if req_id is None:
|
|
149
|
+
return None
|
|
150
|
+
return error(-32601, f"method not found: {method}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _respond(message: Any) -> Optional[Any]:
|
|
154
|
+
"""Response for one parsed message (a request object or a legacy batch)."""
|
|
155
|
+
if isinstance(message, list):
|
|
156
|
+
replies = [r for r in (_respond(m) for m in message) if r is not None]
|
|
157
|
+
return replies or None
|
|
158
|
+
if not isinstance(message, dict):
|
|
159
|
+
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
|
|
160
|
+
try:
|
|
161
|
+
return _handle(message)
|
|
162
|
+
except Exception as exc: # never die on one bad request
|
|
163
|
+
logger.debug("mcp: handler error: %s", exc)
|
|
164
|
+
if message.get("id") is None:
|
|
165
|
+
return None
|
|
166
|
+
return {"jsonrpc": "2.0", "id": message["id"],
|
|
167
|
+
"error": {"code": -32603, "message": str(exc)}}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def serve(stdin=None, stdout=None) -> None:
|
|
171
|
+
"""Run the stdio MCP server loop until EOF."""
|
|
172
|
+
stdin = stdin or sys.stdin
|
|
173
|
+
stdout = stdout or sys.stdout
|
|
174
|
+
for line in stdin:
|
|
175
|
+
line = line.strip()
|
|
176
|
+
if not line:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
message = json.loads(line)
|
|
180
|
+
except ValueError:
|
|
181
|
+
reply: Any = {"jsonrpc": "2.0", "id": None,
|
|
182
|
+
"error": {"code": -32700, "message": "parse error"}}
|
|
183
|
+
else:
|
|
184
|
+
reply = _respond(message)
|
|
185
|
+
if reply is not None:
|
|
186
|
+
stdout.write(json.dumps(reply, ensure_ascii=False) + "\n")
|
|
187
|
+
stdout.flush()
|
subcortex/metrics.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Simple in-memory counters + uptime, exposed by /stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Metrics:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
self._started = time.time()
|
|
14
|
+
self._counts: Dict[str, int] = {}
|
|
15
|
+
self._latency_totals: Dict[str, float] = {}
|
|
16
|
+
self._sums: Dict[str, float] = {}
|
|
17
|
+
self._notes: Dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
def record(self, name: str, latency_ms: Optional[float] = None) -> None:
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._counts[name] = self._counts.get(name, 0) + 1
|
|
22
|
+
if latency_ms is not None:
|
|
23
|
+
self._latency_totals[name] = self._latency_totals.get(name, 0.0) + latency_ms
|
|
24
|
+
|
|
25
|
+
def add(self, name: str, amount: float) -> None:
|
|
26
|
+
"""Accumulate a quantity (tokens, dollars, characters)."""
|
|
27
|
+
with self._lock:
|
|
28
|
+
self._sums[name] = self._sums.get(name, 0) + amount
|
|
29
|
+
|
|
30
|
+
def note(self, name: str, value: str) -> None:
|
|
31
|
+
"""Remember the latest value of something (e.g. the model that answered)."""
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._notes[name] = value
|
|
34
|
+
|
|
35
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
36
|
+
with self._lock:
|
|
37
|
+
counts = dict(self._counts)
|
|
38
|
+
totals = dict(self._latency_totals)
|
|
39
|
+
sums = {k: round(v, 6) if isinstance(v, float) else v for k, v in self._sums.items()}
|
|
40
|
+
notes = dict(self._notes)
|
|
41
|
+
started = self._started
|
|
42
|
+
avg = {
|
|
43
|
+
name: round(totals[name] / counts[name], 2)
|
|
44
|
+
for name in totals
|
|
45
|
+
if counts.get(name)
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
"uptime_s": round(time.time() - started, 1),
|
|
49
|
+
"counts": counts,
|
|
50
|
+
"avg_latency_ms": avg,
|
|
51
|
+
"totals": sums,
|
|
52
|
+
"latest": notes,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
METRICS = Metrics()
|