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/evalset.py ADDED
@@ -0,0 +1,241 @@
1
+ """Labeled examples for calibrating subcortex's questions and thresholds.
2
+
3
+ A decision model's probability is not an accuracy: thresholds only mean
4
+ something against labeled data, per backend and per question wording. These
5
+ examples back ``subcortex eval`` and the calibration notes in docs/.
6
+
7
+ PROMPTS: (prompt, simple) — simple = a lookup or a small, local edit.
8
+ OUTPUTS: (task, tool call, output, needed) — needed = the output's middle holds
9
+ details the task relies on. Failing commands are left out: subcortex never
10
+ trims output that looks like a failure, whatever the model says.
11
+
12
+ The questions and thresholds were chosen on PROMPTS/OUTPUTS. HELDOUT_* were
13
+ written afterwards and never used for choosing anything — including requests
14
+ that sound small but aren't ("fix the bug") and needed output that looks like
15
+ noise — so they show whether a rule generalizes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import List, Tuple
21
+
22
+ SIMPLE = [
23
+ "what is 2+2?",
24
+ "what does git rebase do?",
25
+ "fix the typo 'recieve' in README.md",
26
+ "rename the variable tmp to buffer in utils.py",
27
+ "what port does the dev server use?",
28
+ "show me the last commit message",
29
+ "is python's dict ordered?",
30
+ "add a newline at the end of main.go",
31
+ "what's the difference between let and const in JS?",
32
+ "bump the version in package.json to 1.4.2",
33
+ "run the tests",
34
+ "which node version does this repo need?",
35
+ "delete the unused import of os in cli.py",
36
+ "what does the -p flag do in mkdir?",
37
+ "change the default port from 8080 to 3000 in config.yaml",
38
+ "print the current git branch",
39
+ "add .DS_Store to .gitignore",
40
+ "how do I list docker containers?",
41
+ 'make the error message in login.ts say "Invalid password" instead of "Error"',
42
+ "what license is this project under?",
43
+ ]
44
+
45
+ COMPLEX = [
46
+ "implement OAuth login with Google and GitHub, including token refresh",
47
+ "refactor the parser into a visitor pattern across all modules",
48
+ "debug the intermittent race condition in the job scheduler",
49
+ "migrate the database schema to support multi-tenancy",
50
+ "why is the docker build so slow? speed it up",
51
+ "add end-to-end tests for the checkout flow and fix whatever fails",
52
+ "port the CLI from argparse to click and keep all flags compatible",
53
+ "find where we parse the CSV upload and add a size limit",
54
+ "set up CI with GitHub Actions for lint, test and release",
55
+ "our memory usage grows over time in production, find the leak",
56
+ "add pagination to every list endpoint in the API",
57
+ "upgrade the project from React 17 to 18 and fix the breakages",
58
+ "design a caching layer for the product catalog with invalidation",
59
+ "the build fails on Linux but not macOS, figure out why",
60
+ "write a migration script that backfills user timezones from IP logs",
61
+ "split the monolith's billing module into its own service",
62
+ "make the app work offline with a service worker and sync queue",
63
+ "add role-based access control to the admin panel",
64
+ "investigate why p99 latency doubled after yesterday's deploy",
65
+ "convert the codebase from JavaScript to TypeScript with strict mode",
66
+ ]
67
+
68
+ PROMPTS: List[Tuple[str, bool]] = [(p, True) for p in SIMPLE] + [(p, False) for p in COMPLEX]
69
+
70
+
71
+ def _lines(template: str, n: int) -> str:
72
+ return "\n".join(template.format(i=i, j=i * 7 % 97, k=i * 13 % 1000) for i in range(n))
73
+
74
+
75
+ _NPM = _lines("npm http fetch GET 200 https://registry.npmjs.org/pkg-{i} {j}ms (cache miss)", 60) \
76
+ + "\nadded 1402 packages, and audited 1403 packages in 38s\nfound 0 vulnerabilities"
77
+ _CARGO = _lines(" Compiling crate_{i} v0.{j}.{i}", 70) + "\n Finished dev [unoptimized + debuginfo] target(s) in 41.20s"
78
+ _PIP = _lines("Collecting package-{i}==1.{j}.0\n Downloading package_{i}-1.{j}.0-py3-none-any.whl ({k} kB)", 40) \
79
+ + "\nSuccessfully installed " + " ".join(f"package-{i}-1.{i * 7 % 97}.0" for i in range(40))
80
+ _DOCKER_PULL = _lines("{i:012x}: Pull complete", 50) + "\nDigest: sha256:" + "ab" * 32 + "\nStatus: Downloaded newer image for node:20"
81
+ _PYTEST_OK = _lines("tests/test_module_{i}.py::test_case_{j} PASSED [{k:3d}%]", 70) \
82
+ + "\n============================== 70 passed in 12.31s =============================="
83
+ _WEBPACK = _lines("[webpack] <s> [webpack.Progress] {i}% building {j}/{k} entries {i}/{j} dependencies", 60) \
84
+ + "\nwebpack 5.90.0 compiled successfully in 8412 ms"
85
+ _APT = _lines("Get:{i} http://archive.ubuntu.com/ubuntu jammy/main amd64 lib{j}-dev amd64 1.{i}-1 [{k} kB]", 50) \
86
+ + "\nFetched 48.2 MB in 6s (8,120 kB/s)"
87
+ _LS_NODE = _lines("node_modules/pkg-{i}/package.json\nnode_modules/pkg-{i}/index.js\nnode_modules/pkg-{i}/README.md", 50)
88
+ _TERRAFORM = _lines("aws_security_group_rule.rule_{i}: Refreshing state... [id=sgrule-{k}]", 60)
89
+ _GIT_LOG = _lines("{k:07x} chore(deps): bump dependency-{i} from 1.{j}.0 to 1.{j}.1", 60)
90
+
91
+ _GREP_CSV = _lines("src/ui/table_{i}.tsx:{j}: rows.map(row => <Row key={{row.id}} {{...row}} />)", 30) + "\n" + "\n".join([
92
+ "src/api/upload.py:88:def parse_csv(file: UploadFile) -> list[dict]:",
93
+ "src/api/upload.py:91: reader = csv.DictReader(io.TextIOWrapper(file.file, encoding='utf-8'))",
94
+ "src/api/upload.py:94: return [row for row in reader]",
95
+ "src/api/routes.py:41: rows = parse_csv(upload)",
96
+ ]) + "\n" + _lines("src/ui/list_{i}.tsx:{j}: items.map(item => <Item key={{item.id}} />)", 30)
97
+ _DOCKER_BUILD = "\n".join([
98
+ "#1 [internal] load build definition from Dockerfile", "#1 DONE 0.0s",
99
+ "#4 [builder 1/7] FROM python:3.12-slim", "#4 DONE 0.4s",
100
+ "#5 [builder 2/7] COPY . /app", "#5 DONE 41.3s",
101
+ "#6 [builder 3/7] RUN pip install -r requirements.txt", "#6 212.7s (no cache: COPY . /app changed)",
102
+ "#7 [builder 4/7] RUN npm ci", "#7 DONE 98.0s",
103
+ "#8 [builder 5/7] RUN npm run build", "#8 DONE 64.2s",
104
+ ]) + "\n" + _lines("#9 sending tarball layer {i} {k}kB", 40)
105
+ _SESSION_PY = "\n".join([
106
+ "import time", "from .tokens import decode, TokenExpired", "",
107
+ "class Session:",
108
+ " def __init__(self, user_id, expires_at):",
109
+ " self.user_id = user_id", " self.expires_at = expires_at", "",
110
+ " def expired(self, now=None):",
111
+ " return (now or time.time()) > self.expires_at", "",
112
+ "def login(token):",
113
+ " claims = decode(token)",
114
+ " session = Session(claims['sub'], claims['exp'])",
115
+ " if session.expired():",
116
+ " raise TokenExpired(claims['sub'])",
117
+ " return session",
118
+ ] * 4)
119
+ _PYTEST_LOGIN = _lines("tests/test_module_{i}.py::test_case_{j} PASSED", 30) + "\n" + "\n".join([
120
+ "tests/auth/test_session.py::test_login_rejects_expired_token PASSED",
121
+ "tests/auth/test_session.py::test_login_accepts_clock_skew XFAIL (skew not handled)",
122
+ "tests/auth/test_session.py::test_login_sets_cookie PASSED",
123
+ ]) + "\n" + _lines("tests/test_module_{i}.py::test_other_{j} PASSED", 30)
124
+ _DU = "\n".join(f"{size}\t{name}" for size, name in [
125
+ ("48G", "./data/raw_exports"), ("12G", "./.git"), ("3.1G", "./node_modules"), ("820M", "./build"),
126
+ ("410M", "./logs"), ("96M", "./coverage")] + [(f"{i}K", f"./src/module_{i}") for i in range(80)])
127
+ _GIT_DIFF = "\n".join([
128
+ "diff --git a/src/api/upload.py b/src/api/upload.py",
129
+ "@@ -85,10 +85,14 @@ def parse_csv(file: UploadFile) -> list[dict]:",
130
+ "+MAX_UPLOAD_BYTES = 10 * 1024 * 1024",
131
+ "+ if file.size and file.size > MAX_UPLOAD_BYTES:",
132
+ "+ raise HTTPException(413, 'CSV upload too large')",
133
+ " reader = csv.DictReader(io.TextIOWrapper(file.file, encoding='utf-8'))",
134
+ ] * 8)
135
+ _EXPLAIN = "\n".join([
136
+ "Gather (cost=1000.00..184732.41 rows=1 width=64) (actual time=1893.112..1905.442 rows=12 loops=1)",
137
+ " Workers Planned: 2",
138
+ " -> Parallel Seq Scan on orders (cost=0.00..183732.31 rows=1 width=64) (actual time=1874.2..1880.9 rows=4 loops=3)",
139
+ " Filter: ((customer_email)::text = 'a@b.com'::text)",
140
+ " Rows Removed by Filter: 4166663",
141
+ "Planning Time: 0.211 ms", "Execution Time: 1905.520 ms",
142
+ ] * 6)
143
+ _KUBECTL = "NAME READY STATUS RESTARTS AGE\n" + "\n".join(
144
+ [f"api-7d9f8c6b5-{i:05d} 1/1 Running 0 2d" for i in range(20)]
145
+ + ["worker-5c4b8d7f9-x2k9p 0/1 ImagePullBackOff 0 14m",
146
+ "worker-5c4b8d7f9-q8w3e 0/1 ImagePullBackOff 0 14m"]
147
+ + [f"cron-{i:05d} 0/1 Completed 0 3h" for i in range(20)])
148
+ _NPM_LS = "app@2.3.0 /srv/app\n" + _lines("├─┬ @scope/widget-{i}@3.{j}.0\n│ └── react@17.0.2 deduped", 25) \
149
+ + "\n├── react@17.0.2\n├── react-dom@17.0.2\n└─┬ react-router-dom@5.3.4\n └── react@17.0.2 deduped"
150
+ _CURL = "\n".join([
151
+ "* Trying 10.0.4.12:443...", "* Connected to api.example.com (10.0.4.12) port 443",
152
+ "> OPTIONS /v2/orders HTTP/2", "> Origin: https://app.example.com",
153
+ "> Access-Control-Request-Method: POST",
154
+ "< HTTP/2 204", "< access-control-allow-origin: https://admin.example.com",
155
+ "< access-control-allow-methods: GET, OPTIONS", "< vary: Origin",
156
+ ] * 5)
157
+ _PROFILE = " ncalls tottime percall cumtime percall filename:lineno(function)\n" + "\n".join([
158
+ " 120000 14.212 0.000 31.877 0.000 cache.py:48(_store)",
159
+ " 120000 9.101 0.000 9.101 0.000 {method 'append' of 'list' objects}",
160
+ " 1 0.002 0.002 42.120 42.120 worker.py:12(run)",
161
+ ]) + "\n" + _lines(" {k} 0.00{i:02d} 0.000 0.00{i:02d} 0.000 util_{i}.py:{j}(helper_{i})", 60)
162
+ _ENV = "\n".join([f"VAR_{i}=value_{i}" for i in range(40)]
163
+ + ["DATABASE_URL=postgres://app@db.internal:5433/app", "DATABASE_POOL=5", "PGSSLMODE=require"]
164
+ + [f"OTHER_{i}=x" for i in range(40)])
165
+
166
+ OUTPUTS: List[Tuple[str, str, str, bool]] = [
167
+ ("fix the failing login test in auth/session.py", "Bash: npm install", _NPM, False),
168
+ ("find where we parse the CSV upload and add a size limit", "Bash: cargo build", _CARGO, False),
169
+ ("rename the variable tmp to buffer in utils.py", "Bash: pip install -r requirements.txt", _PIP, False),
170
+ ("fix the typo in README.md", "Bash: docker pull node:20", _DOCKER_PULL, False),
171
+ ("add pagination to the orders endpoint", "Bash: pytest", _PYTEST_OK, False),
172
+ ('make the error message in login.ts say "Invalid password"', "Bash: npm run build", _WEBPACK, False),
173
+ ("set up CI with GitHub Actions", "Bash: apt-get install -y build-essential", _APT, False),
174
+ ("fix the failing login test in auth/session.py", "Bash: find node_modules -name '*.js*'", _LS_NODE, False),
175
+ ("rename the output variable in main.tf", "Bash: terraform plan", _TERRAFORM, False),
176
+ ("add a size limit to the CSV upload", "Bash: git log --oneline -60", _GIT_LOG, False),
177
+ ("upgrade the project from React 17 to 18", "Bash: cargo build", _CARGO, False),
178
+ ("why is the docker build so slow? speed it up", "Bash: npm install", _NPM, False),
179
+ ("find where we parse the CSV upload and add a size limit", "Bash: rg -n 'csv|rows' src", _GREP_CSV, True),
180
+ ("why is the docker build so slow? speed it up", "Bash: docker build --progress=plain .", _DOCKER_BUILD, True),
181
+ ("fix the failing login test in auth/session.py", "Bash: cat auth/session.py", _SESSION_PY, True),
182
+ ("fix the failing login test in auth/session.py", "Bash: pytest -v", _PYTEST_LOGIN, True),
183
+ ("find what is using all the disk space", "Bash: du -sh ./*", _DU, True),
184
+ ("review my changes before I commit", "Bash: git diff", _GIT_DIFF, True),
185
+ ("speed up the slow orders-by-email query", "Bash: psql -c 'EXPLAIN ANALYZE ...'", _EXPLAIN, True),
186
+ ("why is the worker deployment not ready?", "Bash: kubectl get pods", _KUBECTL, True),
187
+ ("upgrade the project from React 17 to 18", "Bash: npm ls react", _NPM_LS, True),
188
+ ("why can't the app connect to the database?", "Bash: env", _ENV, True),
189
+ ("debug the CORS error when posting orders", "Bash: curl -v -X OPTIONS https://api.example.com/v2/orders", _CURL, True),
190
+ ("our worker gets slower over time, find the hotspot", "Bash: python -m cProfile worker.py", _PROFILE, True),
191
+ ]
192
+
193
+
194
+ # -- held out ------------------------------------------------------------------------------
195
+
196
+ HELDOUT_SIMPLE = [
197
+ "what's the capital of France?", "undo my last git commit but keep the changes",
198
+ "how do I exit vim?", "rename README.txt to README.md", "what does `ls -la` show?",
199
+ "remove the trailing whitespace on line 12 of app.py", "which python version is active?",
200
+ "is 1024 a power of two?", "add 'node_modules' to .dockerignore", "what port is postgres on by default?",
201
+ "change the page title in index.html to 'Dashboard'", "show the diff of my staged changes",
202
+ ]
203
+ HELDOUT_COMPLEX = [
204
+ "just make the tests pass", # sounds small, is open-ended
205
+ "fix the bug", # vague, needs investigation
206
+ "make it faster", # vague optimisation
207
+ "add dark mode to the whole app",
208
+ "why does login fail only in production?",
209
+ "rename the User model to Account everywhere, including the database",
210
+ "migrate from REST to GraphQL for the mobile client",
211
+ "the CI is flaky, figure out which test and fix it",
212
+ "implement rate limiting per API key with Redis",
213
+ "clean up this codebase",
214
+ "add i18n support with French and German translations",
215
+ "our docker image is 2GB, get it under 300MB",
216
+ ]
217
+ HELDOUT_PROMPTS: List[Tuple[str, bool]] = ([(p, True) for p in HELDOUT_SIMPLE]
218
+ + [(p, False) for p in HELDOUT_COMPLEX])
219
+
220
+ HELDOUT_OUTPUTS: List[Tuple[str, str, str, bool]] = [
221
+ # disposable for the task
222
+ ("rename the prop `title` to `heading` in Card.tsx", "Bash: yarn install", _lines("[{i}/4] Fetching package-{i}@1.{j}.0 ...", 80) + "\nDone in 21.3s.", False),
223
+ ("update the README badge", "Bash: gradle build", _lines("> Task :module{i}:compileJava UP-TO-DATE", 90) + "\nBUILD SUCCESSFUL in 12s", False),
224
+ ("fix the typo in the footer", "Bash: docker compose up -d", _lines("Container app-svc{i}-1 Started", 70), False),
225
+ ("bump lodash to 4.17.21", "Bash: go mod download -x", _lines("# get https://proxy.golang.org/mod{i}/@v/v1.{j}.0.zip: 200 OK ({k}ms)", 60), False),
226
+ ("add a unit test for parse_date", "Bash: pip install -e .", _lines("Requirement already satisfied: lib{i}>=1.{j} in ./venv/lib/python3.12/site-packages", 70), False),
227
+ ("change the button color to blue", "Bash: npx tsc --noEmit", _lines("Checking src/components/C{i}.tsx", 90) + "\nDone.", False),
228
+ # needed for the task
229
+ ("why is the API slow on /orders?", "Bash: grep -c 'GET /orders' access.log && tail -n 80 access.log",
230
+ _lines("10.0.0.{i} - - [22/Sep/2026:10:{j:02d}:00] \"GET /orders?page={i} HTTP/1.1\" 200 512 rt={k}ms", 80), True),
231
+ ("which of our dependencies have known vulnerabilities?", "Bash: npm audit",
232
+ _lines("# npm audit report\n\npackage-{i} <1.{j}.0\nSeverity: high\nPrototype Pollution - https://github.com/advisories/GHSA-{k}\nfix available via `npm audit fix`", 12), True),
233
+ ("find all TODOs about authentication", "Bash: rg -n TODO src",
234
+ _lines("src/mod{i}.py:{j}: # TODO: tidy imports", 40) + "\nsrc/auth/session.py:88: # TODO: rotate refresh tokens\nsrc/auth/oauth.py:41: # TODO: validate state param\n" + _lines("src/ui{i}.ts:{j}: // TODO: styles", 40), True),
235
+ ("what changed in the last release?", "Bash: git log --stat v1.4.0..v1.5.0",
236
+ _lines("commit {k:07x}\n feat(billing): proration for plan changes #{i}\n src/billing/proration.py | {j} +++---", 20), True),
237
+ ("is the disk full on the build server?", "Bash: df -h",
238
+ "Filesystem Size Used Avail Use% Mounted on\n" + _lines("/dev/disk{i} 100G {j}G {k}G {j}% /mnt/vol{i}", 60), True),
239
+ ("which process is holding port 8080?", "Bash: lsof -i -P",
240
+ _lines("proc{i} {k} user {i}u IPv4 0x{j:x} 0t0 TCP *:{k} (LISTEN)", 60) + "\njava 4242 user 7u IPv4 0xabc 0t0 TCP *:8080 (LISTEN)\n" + _lines("proc{i} {k} user {i}u IPv4 0x{j:x} 0t0 TCP *:{j} (LISTEN)", 30), True),
241
+ ]
subcortex/hook.py ADDED
@@ -0,0 +1,254 @@
1
+ """Hardened entry point for every command-hook TUI.
2
+
3
+ subcortex-hook <tui> [<event>] (console script)
4
+ subcortex hook <tui> [<event>] (same thing, routed before argparse)
5
+ python -m subcortex.hook <tui> [<event>]
6
+
7
+ The hook JSON arrives on stdin; the response (if any) goes to stdout. The
8
+ process contract is what keeps TUIs safe, so it is enforced here, not in the
9
+ adapters:
10
+
11
+ - exit status is ALWAYS 0 — even for bad arguments, unknown TUIs/events,
12
+ invalid JSON, exceptions, ``SystemExit`` or ``KeyboardInterrupt``. (Exit 2
13
+ means "block" in Claude Code and several of its imitators.)
14
+ - a watchdog ends the process silently (exit 0, no output) once the
15
+ ``hooks.budget_s`` wall-clock budget is spent, so a hung daemon can never
16
+ stall a turn.
17
+ - stdout/stderr are captured while work runs; only the final response is
18
+ written, as one line. Nothing is ever written to stderr (some TUIs surface
19
+ it to the model or the user) unless ``SUBCORTEX_DEBUG`` is set.
20
+ - the adapter's ``guard`` drops any response carrying a blocking field.
21
+
22
+ Failures are appended to ``<data_dir>/hooks.log``; with ``SUBCORTEX_DEBUG=1``
23
+ every invocation is logged there too.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import io
29
+ import json
30
+ import os
31
+ import sys
32
+ import threading
33
+ import time
34
+ import traceback
35
+ from typing import Any, Dict, List, Optional
36
+
37
+ LOG_MAX_BYTES = 1_000_000
38
+
39
+
40
+ def _log(line: str) -> None:
41
+ try:
42
+ from .config import data_dir
43
+
44
+ path = data_dir() / "hooks.log"
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ if path.is_file() and path.stat().st_size > LOG_MAX_BYTES:
47
+ path.replace(path.with_suffix(".log.1"))
48
+ with open(path, "a", encoding="utf-8") as fh:
49
+ fh.write(time.strftime("%Y-%m-%d %H:%M:%S ") + line.rstrip() + "\n")
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def run(tui: str, event_name: Optional[str], raw: str,
55
+ cfg: Optional[Dict[str, Any]] = None, client: Any = None,
56
+ commits: Optional[List[Any]] = None) -> Optional[str]:
57
+ """Process one hook invocation; returns the stdout text or None.
58
+
59
+ Pure with respect to the process (no exit, no stream handling) so tests and
60
+ the installer self-test can call it directly. May raise; ``main`` doesn't.
61
+ """
62
+ from . import policy
63
+ from .adapters import get_adapter
64
+ from .adapters.base import POST_COMPACT, PRE_COMPACT, PROMPT, SESSION_START, TOOL_OUTPUT
65
+ from .config import load_config
66
+
67
+ adapter = get_adapter(tui)
68
+ if adapter is None:
69
+ return None
70
+ try:
71
+ payload = json.loads(raw) if raw and raw.strip() else {}
72
+ except ValueError:
73
+ return None
74
+ if not isinstance(payload, dict):
75
+ return None
76
+ resolved = adapter.resolve(event_name or payload.get(adapter.event_field))
77
+ if resolved is None:
78
+ return None
79
+ name, kind = resolved
80
+ event = adapter.parse(name, kind, payload)
81
+ if event is None:
82
+ return None
83
+ # Consumed state (restored snapshots) is committed only after delivery.
84
+ event.commits = commits
85
+
86
+ def on_delivery(kind_: str, **fields: Any) -> None:
87
+ """Count it in the ledger (``subcortex stats``) once the response is out."""
88
+ from . import ledger
89
+
90
+ action = lambda: ledger.record(kind_, adapter.name, **fields) # noqa: E731
91
+ if commits is None:
92
+ action()
93
+ else:
94
+ commits.append(action)
95
+
96
+ cfg = cfg or load_config()
97
+ if client is None:
98
+ from .client import DaemonClient
99
+
100
+ client = DaemonClient(cfg)
101
+
102
+ # The adapter can tell a compaction happened (e.g. a manual /compress).
103
+ compacted = bool(event.extra.get("compacted"))
104
+ if compacted and kind != PRE_COMPACT:
105
+ policy.mark_compacted(event.session_id, tui=adapter.name)
106
+
107
+ response = None
108
+ if kind == PROMPT:
109
+ policy.remember_prompt(event.session_id, event.prompt, tui=adapter.name)
110
+ # The slow part (a daemon round trip) first, the restore last: context is
111
+ # claimed only when there is budget left to deliver it.
112
+ hint = policy.prompt_hint(event.prompt, cfg, client.classify) if adapter.delivers_hints else None
113
+ restored = adapter.prompt_context(event, cfg)
114
+ text = "\n\n".join(p for p in (restored, hint) if p)
115
+ response = adapter.render_prompt(event, text) if text else None
116
+ if response is not None:
117
+ if hint:
118
+ on_delivery("hint")
119
+ if restored:
120
+ on_delivery("restore")
121
+ elif kind == TOOL_OUTPUT:
122
+ replacement = policy.trim_output(
123
+ event.output, cfg, client.judge, event.tool, event.tool_input, event.failed,
124
+ task=event.extra.get("task") or policy.last_prompt(event.session_id, tui=adapter.name))
125
+ # Some TUIs can only deliver restored context alongside a tool result.
126
+ context = adapter.output_context(event, cfg)
127
+ if context:
128
+ event.extra["context"] = context
129
+ if replacement or event.extra.get("context"):
130
+ response = adapter.render_tool_output(event, replacement)
131
+ if response is not None and replacement:
132
+ on_delivery("trim", before=len(event.output or ""), after=len(replacement))
133
+ if response is not None and context:
134
+ on_delivery("restore")
135
+ elif kind == PRE_COMPACT:
136
+ if event.messages is not None:
137
+ policy.save_snapshot(event.session_id, event.messages, cfg, event.trigger, tui=adapter.name)
138
+ else:
139
+ policy.snapshot_transcript(event.session_id, event.transcript_path, cfg, event.trigger,
140
+ tui=adapter.name)
141
+ if compacted: # only once the snapshot exists
142
+ policy.mark_compacted(event.session_id, tui=adapter.name)
143
+ response = adapter.render_pre_compact(event)
144
+ elif kind == POST_COMPACT:
145
+ policy.mark_compacted(event.session_id, tui=adapter.name)
146
+ response = adapter.render_post_compact(event)
147
+ elif kind == SESSION_START and adapter.is_after_compaction(event):
148
+ context = policy.restore_snapshot(event.session_id, cfg, tui=adapter.name, commits=commits)
149
+ response = adapter.render_session_start(event, context) if context else None
150
+ if response is not None:
151
+ on_delivery("restore")
152
+
153
+ response = adapter.guard(kind, response)
154
+ if response is None:
155
+ return None
156
+ return response if isinstance(response, str) else json.dumps(response, ensure_ascii=False)
157
+
158
+
159
+ # Held while the response line is written: the watchdog must not cut it in half.
160
+ _WRITING = threading.Lock()
161
+ MAX_STDIN_BYTES = 64 * 1024 * 1024
162
+
163
+
164
+ def _bail() -> None:
165
+ # Budget exhausted: leave without output. os._exit skips cleanup on purpose.
166
+ if _WRITING.acquire(blocking=False):
167
+ os._exit(0)
168
+
169
+
170
+ def _budget(cfg: Dict[str, Any]) -> float:
171
+ try:
172
+ budget = float((cfg.get("hooks") or {}).get("budget_s", 4.0))
173
+ except (TypeError, ValueError):
174
+ return 4.0
175
+ return min(max(budget, 0.5), 30.0) if budget == budget else 4.0 # NaN -> default
176
+
177
+
178
+ def main(argv: Optional[List[str]] = None) -> int:
179
+ """Hook process entry. Returns 0 on every path."""
180
+ real_stdout = sys.stdout
181
+ real_stderr = sys.stderr
182
+ debug = bool(os.environ.get("SUBCORTEX_DEBUG"))
183
+ # Streams are captured before anything else can print.
184
+ sys.stdout = io.StringIO()
185
+ if not debug:
186
+ sys.stderr = io.StringIO()
187
+ timer = None
188
+ started = time.perf_counter()
189
+ args: List[str] = []
190
+ backstop = None
191
+ try:
192
+ args = [str(a) for a in (sys.argv[1:] if argv is None else argv)]
193
+ tui = args[0] if args else ""
194
+ event_name = args[1] if len(args) > 1 else None
195
+
196
+ from .config import load_config
197
+
198
+ cfg = load_config()
199
+ budget = _budget(cfg)
200
+ timer = threading.Timer(budget, _bail)
201
+ timer.daemon = True
202
+ timer.start()
203
+ # The timer needs the GIL; C code (a regex, a JSON parse of a huge
204
+ # payload) can hold it for longer than the budget. faulthandler's
205
+ # watchdog is a C thread: it ends the process regardless (status 1,
206
+ # which the installed "|| true" guard turns into 0).
207
+ import faulthandler
208
+
209
+ backstop = open(os.devnull, "w")
210
+ faulthandler.dump_traceback_later(budget + 1.0, exit=True, file=backstop)
211
+
212
+ stdin = getattr(sys.stdin, "buffer", None)
213
+ data = stdin.read(MAX_STDIN_BYTES + 1) if stdin is not None else b""
214
+ if len(data) > MAX_STDIN_BYTES:
215
+ raise ValueError("hook payload too large")
216
+ commits: List[Any] = []
217
+ out = run(tui, event_name, data.decode("utf-8", "replace"), cfg, commits=commits)
218
+
219
+ with _WRITING:
220
+ timer.cancel()
221
+ sys.stdout, sys.stderr = real_stdout, real_stderr
222
+ if out:
223
+ real_stdout.write(out + "\n")
224
+ real_stdout.flush()
225
+ for commit in commits: # delivered: consumed state can go
226
+ try:
227
+ commit()
228
+ except Exception:
229
+ pass
230
+ if debug:
231
+ ms = (time.perf_counter() - started) * 1000
232
+ _log(f"{' '.join(args)} ok {ms:.0f}ms -> {out[:200] if out else '(no output)'}")
233
+ except BaseException: # noqa: BLE001 — SystemExit/KeyboardInterrupt included, by design
234
+ if timer is not None:
235
+ timer.cancel()
236
+ sys.stdout, sys.stderr = real_stdout, real_stderr
237
+ _log(f"{' '.join(args)} failed:\n{traceback.format_exc()}")
238
+ finally:
239
+ if backstop is not None:
240
+ import faulthandler
241
+
242
+ faulthandler.cancel_dump_traceback_later()
243
+ backstop.close()
244
+ return 0
245
+
246
+
247
+ def entry() -> None:
248
+ """Console-script entry (``subcortex-hook``): exit status is always 0."""
249
+ main()
250
+ sys.exit(0)
251
+
252
+
253
+ if __name__ == "__main__":
254
+ entry()
@@ -0,0 +1,62 @@
1
+ """Registry of TUI installers (hook-, plugin- and MCP-based)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Dict, List, Optional
7
+
8
+ from ..tuis import canonical
9
+ from .base import Installer
10
+
11
+ # canonical name -> "module:Class" (relative to this package)
12
+ _INSTALLERS: Dict[str, str] = {
13
+ # command hooks
14
+ "claude-code": "claude_family:ClaudeCodeInstaller",
15
+ "qoder": "claude_family:QoderInstaller",
16
+ "codebuddy": "claude_family:CodeBuddyInstaller",
17
+ "droid": "claude_family:DroidInstaller",
18
+ "junie": "claude_family:JunieInstaller",
19
+ "devin": "claude_family:DevinInstaller",
20
+ "codex": "codex:CodexInstaller",
21
+ "open-interpreter": "codex:OpenInterpreterInstaller",
22
+ "gemini-cli": "gemini_family:GeminiCliInstaller",
23
+ "qwen-code": "gemini_family:QwenCodeInstaller",
24
+ "cursor": "cursor:CursorInstaller",
25
+ "copilot": "copilot:CopilotInstaller",
26
+ "kimi-code": "kimi_code:KimiCodeInstaller",
27
+ "openhands": "openhands:OpenHandsInstaller",
28
+ "grok-build": "more_hooks:GrokBuildInstaller",
29
+ "docker-agent": "more_hooks:DockerAgentInstaller",
30
+ "letta": "more_hooks:LettaInstaller",
31
+ "vibe": "more_hooks:VibeInstaller",
32
+ # plugins
33
+ "opencode": "opencode:OpenCodeInstaller",
34
+ "kilo": "opencode:KiloInstaller",
35
+ "amp": "amp:AmpInstaller",
36
+ "pi": "pi_cline:PiInstaller",
37
+ "cline": "pi_cline:ClineInstaller",
38
+ # MCP only
39
+ "crush": "crush:CrushInstaller",
40
+ "goose": "goose:GooseInstaller",
41
+ "warp": "mcp_only:WarpInstaller",
42
+ "zed": "mcp_only:ZedInstaller",
43
+ "auggie": "mcp_only:AuggieInstaller",
44
+ "kiro": "mcp_only:KiroInstaller",
45
+ }
46
+
47
+
48
+ def canonical_name(name: str) -> Optional[str]:
49
+ return canonical(name, _INSTALLERS)
50
+
51
+
52
+ def get_installer(name: str, mcp: bool = False) -> Optional[Installer]:
53
+ key = canonical_name(name)
54
+ if key is None:
55
+ return None
56
+ module_name, cls_name = _INSTALLERS[key].split(":")
57
+ module = importlib.import_module(f".{module_name}", __name__)
58
+ return getattr(module, cls_name)(mcp=mcp)
59
+
60
+
61
+ def names() -> List[str]:
62
+ return sorted(_INSTALLERS)
@@ -0,0 +1,39 @@
1
+ """Amp: a Bun plugin in the system plugin dir (+ optional ``amp.mcpServers``).
2
+
3
+ Amp's Neo plugin API has no command hooks, but its plugins can append hidden
4
+ context to a prompt (``agent.start``) and replace a tool result
5
+ (``tool.result``), so the plugin covers behaviors 1 and 2 natively and 3/4 via
6
+ a rolling snapshot plus compaction detection. See ``plugins/amp/subcortex.ts``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ from .base import Installer, Target, rendered_plugin, mcp_json_target, plugin_file_target
16
+
17
+
18
+ def amp_config_dir() -> Path:
19
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
20
+ return (Path(xdg) if xdg else Path.home() / ".config") / "amp"
21
+
22
+
23
+ class AmpInstaller(Installer):
24
+ name = "amp"
25
+ display_name = "Amp"
26
+ seam = "plugin"
27
+ binaries = ("amp",)
28
+ docs = "https://ampcode.com/docs/customize/plugins"
29
+ supports_mcp = True
30
+ post_install = ("reload plugins in Amp (Ctrl+O → plugins: reload) or restart it; "
31
+ "for `amp -x` runs pass --plugin-ready-timeout")
32
+
33
+ def targets(self) -> List[Target]:
34
+ content = rendered_plugin("amp")
35
+ targets = [plugin_file_target(amp_config_dir() / "plugins" / "subcortex.ts", content)]
36
+ if self.mcp:
37
+ targets.append(mcp_json_target(amp_config_dir() / "settings.json", self.mcp_command(),
38
+ key="amp.mcpServers"))
39
+ return targets