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