subcortex 0.3.0__tar.gz → 0.3.1__tar.gz
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-0.3.0 → subcortex-0.3.1}/PKG-INFO +11 -7
- {subcortex-0.3.0 → subcortex-0.3.1}/README.md +10 -6
- {subcortex-0.3.0 → subcortex-0.3.1}/pyproject.toml +1 -1
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/__init__.py +1 -1
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/cli.py +15 -23
- subcortex-0.3.1/src/subcortex/settings.py +345 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/wizard.py +240 -53
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/PKG-INFO +11 -7
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/SOURCES.txt +2 -0
- subcortex-0.3.1/tests/test_settings.py +120 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_setup.py +124 -24
- {subcortex-0.3.0 → subcortex-0.3.1}/setup.cfg +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/__main__.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/__init__.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/base.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/claude_family.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/codex.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/copilot.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/cursor.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/docker_agent.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/gemini_family.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/grok.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/kimi_code.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/letta_vibe.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/openhands.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/auth.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/__init__.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/base.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/jev.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/laya.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/client.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/config.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/daemon.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/evalset.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/hook.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/__init__.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/amp.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/base.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/claude_family.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/codex.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/copilot.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/crush.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/cursor.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/gemini_family.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/goose.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/kimi_code.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/mcp_only.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/more_hooks.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/opencode.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/openhands.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/pi_cline.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/ledger.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/localhttp.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/mcp_server.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/metrics.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/amp/subcortex.ts +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/cline/subcortex.ts +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/opencode/subcortex.ts +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/pi/subcortex.ts +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/policy.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/presets.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/provision.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/service.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/state.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/transcript.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/tuis.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/ui.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/verdicts.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/dependency_links.txt +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/entry_points.txt +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/requires.txt +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/top_level.txt +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_adapters.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_cli.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_config.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_daemon.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_codex.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_gemini.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_more.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_plugins.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_vendor.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_hook_process.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_installer_base.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_installers.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_jev.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_lifecycle.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_mcp.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_plugins.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_policy.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_state.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_transcript.py +0 -0
- {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_verdicts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subcortex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Local decision layer for coding-agent TUIs: a warm daemon answering System-1 questions in milliseconds
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -97,10 +97,13 @@ curl -fsSL https://raw.githubusercontent.com/pavlealeksic/subcortex/main/install
|
|
|
97
97
|
That installs the CLI into its own environment (uv, pipx or a private venv)
|
|
98
98
|
and starts **`subcortex setup`**, an interactive walk-through:
|
|
99
99
|
|
|
100
|
-
1. **Backend** —
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
1. **Backend** — Jev (hosted, most accurate: pick TypeSafe, OpenRouter or
|
|
101
|
+
another endpoint, paste your key — it is checked with one real decision
|
|
102
|
+
before it is saved, and a wrong one offers retry / keep / use Laya / skip)
|
|
103
|
+
or Laya (local; installs `laya-mlx`/`laya` into a dedicated environment and
|
|
104
|
+
downloads the model).
|
|
105
|
+
2. **Behaviors** — prompt hints, output trimming (with Laya only after an
|
|
106
|
+
explicit OK), compaction snapshots; optionally the advanced settings.
|
|
104
107
|
3. **TUIs** — a checklist of every supported TUI, with the ones found on this
|
|
105
108
|
machine preselected.
|
|
106
109
|
4. **Review** — each file that will change, with its diff on request; every
|
|
@@ -208,8 +211,9 @@ run this inside real work, so every one of these is enforced by tests:
|
|
|
208
211
|
|
|
209
212
|
```sh
|
|
210
213
|
subcortex doctor # check config, backend, daemon, installed hooks
|
|
211
|
-
subcortex config #
|
|
212
|
-
|
|
214
|
+
subcortex config # settings editor in a terminal: every option, validated,
|
|
215
|
+
# with defaults, env overrides and the Jev key; listing otherwise
|
|
216
|
+
subcortex config set thresholds.min_output_chars 8000 # or get / unset / show, for scripts
|
|
213
217
|
subcortex service install # start the daemon at login (launchd / systemd --user)
|
|
214
218
|
subcortex serve # start the background daemon (127.0.0.1:7707)
|
|
215
219
|
subcortex stats # hints, trims (tokens saved), restores and jev cost, per TUI
|
|
@@ -86,10 +86,13 @@ curl -fsSL https://raw.githubusercontent.com/pavlealeksic/subcortex/main/install
|
|
|
86
86
|
That installs the CLI into its own environment (uv, pipx or a private venv)
|
|
87
87
|
and starts **`subcortex setup`**, an interactive walk-through:
|
|
88
88
|
|
|
89
|
-
1. **Backend** —
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
1. **Backend** — Jev (hosted, most accurate: pick TypeSafe, OpenRouter or
|
|
90
|
+
another endpoint, paste your key — it is checked with one real decision
|
|
91
|
+
before it is saved, and a wrong one offers retry / keep / use Laya / skip)
|
|
92
|
+
or Laya (local; installs `laya-mlx`/`laya` into a dedicated environment and
|
|
93
|
+
downloads the model).
|
|
94
|
+
2. **Behaviors** — prompt hints, output trimming (with Laya only after an
|
|
95
|
+
explicit OK), compaction snapshots; optionally the advanced settings.
|
|
93
96
|
3. **TUIs** — a checklist of every supported TUI, with the ones found on this
|
|
94
97
|
machine preselected.
|
|
95
98
|
4. **Review** — each file that will change, with its diff on request; every
|
|
@@ -197,8 +200,9 @@ run this inside real work, so every one of these is enforced by tests:
|
|
|
197
200
|
|
|
198
201
|
```sh
|
|
199
202
|
subcortex doctor # check config, backend, daemon, installed hooks
|
|
200
|
-
subcortex config #
|
|
201
|
-
|
|
203
|
+
subcortex config # settings editor in a terminal: every option, validated,
|
|
204
|
+
# with defaults, env overrides and the Jev key; listing otherwise
|
|
205
|
+
subcortex config set thresholds.min_output_chars 8000 # or get / unset / show, for scripts
|
|
202
206
|
subcortex service install # start the daemon at login (launchd / systemd --user)
|
|
203
207
|
subcortex serve # start the background daemon (127.0.0.1:7707)
|
|
204
208
|
subcortex stats # hints, trims (tokens saved), restores and jev cost, per TUI
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "subcortex"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Local decision layer for coding-agent TUIs: a warm daemon answering System-1 questions in milliseconds"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -615,7 +615,11 @@ def _set_dotted(key: str, value: Any) -> None:
|
|
|
615
615
|
def cmd_config(args: argparse.Namespace) -> int:
|
|
616
616
|
from .config import config_path, secrets_path, unset_config
|
|
617
617
|
|
|
618
|
-
|
|
618
|
+
from .ui import UI
|
|
619
|
+
|
|
620
|
+
action = args.action
|
|
621
|
+
if action is None: # a terminal gets the editor; scripts and pipes get the listing
|
|
622
|
+
action = "edit" if UI().interactive else "show"
|
|
619
623
|
cfg = load_config()
|
|
620
624
|
flat = _flatten(cfg)
|
|
621
625
|
if action == "show":
|
|
@@ -639,8 +643,11 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
639
643
|
if args.value is None:
|
|
640
644
|
print("usage: subcortex config set <key> <value>", file=sys.stderr)
|
|
641
645
|
return 2
|
|
646
|
+
from . import settings
|
|
647
|
+
|
|
642
648
|
try:
|
|
643
|
-
value =
|
|
649
|
+
value = (settings.parse(args.key, args.value) if args.key in settings.BY_KEY
|
|
650
|
+
else _coerce(args.key, args.value))
|
|
644
651
|
except ValueError as exc:
|
|
645
652
|
print(str(exc), file=sys.stderr)
|
|
646
653
|
return 2
|
|
@@ -650,29 +657,14 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
650
657
|
if action == "unset":
|
|
651
658
|
print(f"{args.key} reverted to default" if unset_config(args.key) else f"{args.key} was not set")
|
|
652
659
|
return 0
|
|
653
|
-
# edit: interactive
|
|
654
|
-
from .ui import UI, Cancelled
|
|
655
|
-
|
|
660
|
+
# edit: the interactive settings editor
|
|
656
661
|
ui = UI()
|
|
657
662
|
if not ui.interactive:
|
|
658
|
-
print("
|
|
663
|
+
print("the settings editor needs a terminal; use: subcortex config set <key> <value>", file=sys.stderr)
|
|
659
664
|
return 2
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
options = [(k, k, json.dumps(v)) for k, v in sorted(flat.items())] + [(None, "done", "")]
|
|
664
|
-
key = ui.choose("Change which setting?", options, len(options) - 1)
|
|
665
|
-
if key is None:
|
|
666
|
-
return 0
|
|
667
|
-
while True:
|
|
668
|
-
raw = ui.ask(key, json.dumps(flat[key]).strip('"'))
|
|
669
|
-
try:
|
|
670
|
-
_set_dotted(key, _coerce(key, raw))
|
|
671
|
-
break
|
|
672
|
-
except ValueError as exc:
|
|
673
|
-
ui.warn(str(exc))
|
|
674
|
-
except Cancelled:
|
|
675
|
-
return 130
|
|
665
|
+
from . import settings
|
|
666
|
+
|
|
667
|
+
return settings.edit(ui)
|
|
676
668
|
|
|
677
669
|
|
|
678
670
|
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
@@ -771,7 +763,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
771
763
|
p_service.add_argument("action", choices=["install", "uninstall", "status"])
|
|
772
764
|
p_service.set_defaults(func=cmd_service)
|
|
773
765
|
|
|
774
|
-
p_config = sub.add_parser("config", help="
|
|
766
|
+
p_config = sub.add_parser("config", help="change settings (interactive in a terminal), or show/get/set/unset")
|
|
775
767
|
p_config.add_argument("action", nargs="?", choices=["show", "get", "set", "unset", "edit"])
|
|
776
768
|
p_config.add_argument("key", nargs="?")
|
|
777
769
|
p_config.add_argument("value", nargs="?")
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Every user-facing setting, described once: the interactive editor
|
|
2
|
+
(``subcortex config``) and ``subcortex config set`` both validate through it.
|
|
3
|
+
|
|
4
|
+
A setting is (dotted key, section, label, help, kind, options). Kinds:
|
|
5
|
+
``bool`` · ``choice`` (options["choices"]: [(value, label, hint)], options["other"]
|
|
6
|
+
allows a free value) · ``int``/``float`` (options["min"], options["max"]) ·
|
|
7
|
+
``prob`` (null = the backend's calibrated rule, else 0..1) · ``url`` · ``name``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from .config import DEFAULT_CONFIG, _ENV_OVERRIDES, config_path, load_config, save_config, unset_config
|
|
17
|
+
|
|
18
|
+
Setting = Tuple[str, str, str, str, str, Dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
LAYA_MODELS = [("multilingual", "multilingual", "default · many languages"),
|
|
21
|
+
("english", "english", "English only · smallest"),
|
|
22
|
+
("typed-decisions", "typed-decisions", "tuned for typed decisions")]
|
|
23
|
+
JEV_MODELS = [("jev-1.13.0", "jev-1.13.0", "pinned · the thresholds were calibrated on it"),
|
|
24
|
+
("jev-latest", "jev-latest", "follows new releases · re-check with `subcortex eval`")]
|
|
25
|
+
|
|
26
|
+
SETTINGS: List[Setting] = [
|
|
27
|
+
("backend", "Decisions", "Backend", "who makes the decisions", "choice",
|
|
28
|
+
{"choices": [("jev", "Jev", "hosted by TypeSafe · most accurate · needs a key"),
|
|
29
|
+
("laya", "Laya", "runs on this machine · private · free · no trimming by default")]}),
|
|
30
|
+
("jev.model", "Decisions", "Jev model", "a pinned version keeps the calibrated thresholds meaningful",
|
|
31
|
+
"choice", {"choices": JEV_MODELS, "other": True}),
|
|
32
|
+
("model", "Decisions", "Laya model", "the local model", "choice", {"choices": LAYA_MODELS}),
|
|
33
|
+
("features.prompt_hint", "Behaviors", "Prompt hints",
|
|
34
|
+
"tell the main model when a request looks simple", "bool", {}),
|
|
35
|
+
("features.trim_output", "Behaviors", "Output trimming",
|
|
36
|
+
"cut large routine shell output the request doesn't need", "bool", {}),
|
|
37
|
+
("features.compaction_snapshot", "Behaviors", "Compaction snapshots",
|
|
38
|
+
"carry the last messages across context compaction", "bool", {}),
|
|
39
|
+
("thresholds.prompt_simple_confidence", "Thresholds", "Hint threshold",
|
|
40
|
+
"minimum p(simple) for a hint · empty = the backend's calibrated rule", "prob", {}),
|
|
41
|
+
("thresholds.output_needed_threshold", "Thresholds", "Trim threshold",
|
|
42
|
+
"trim only below this p(needed) · empty = calibrated (Laya: no trimming)", "prob", {}),
|
|
43
|
+
("thresholds.min_output_chars", "Thresholds", "Judge outputs longer than",
|
|
44
|
+
"characters; shorter outputs are always kept", "int", {"min": 1000, "max": 1_000_000}),
|
|
45
|
+
("hooks.budget_s", "Hooks", "Time limit per hook",
|
|
46
|
+
"seconds; after that the hook passes everything through", "float", {"min": 0.5, "max": 30}),
|
|
47
|
+
("hooks.http_timeout_s", "Hooks", "Wait for the daemon",
|
|
48
|
+
"seconds a hook waits for a decision", "float", {"min": 0.2, "max": 30}),
|
|
49
|
+
("hooks.autostart_daemon", "Hooks", "Autostart the daemon",
|
|
50
|
+
"a hook that finds the daemon down starts it", "bool", {}),
|
|
51
|
+
("hooks.head_chars", "Trimming", "Keep from the start",
|
|
52
|
+
"characters of a trimmed output's head", "int", {"min": 0, "max": 100_000}),
|
|
53
|
+
("hooks.tail_chars", "Trimming", "Keep from the end",
|
|
54
|
+
"characters of a trimmed output's tail", "int", {"min": 0, "max": 100_000}),
|
|
55
|
+
("hooks.snapshot_messages", "Compaction", "Messages kept",
|
|
56
|
+
"recent messages restored after compaction", "int", {"min": 1, "max": 50}),
|
|
57
|
+
("hooks.snapshot_chars", "Compaction", "Characters per message",
|
|
58
|
+
"each restored message is cut to this length", "int", {"min": 50, "max": 10_000}),
|
|
59
|
+
("port", "Daemon", "Port", "127.0.0.1 port of the daemon", "int", {"min": 1024, "max": 65535}),
|
|
60
|
+
("jev.base_url", "Jev endpoint", "Base URL", "as given in the provider's docs", "url", {}),
|
|
61
|
+
("jev.api_key_env", "Jev endpoint", "Key variable", "environment variable / stored secret name",
|
|
62
|
+
"name", {}),
|
|
63
|
+
("jev.timeout", "Jev endpoint", "Timeout", "seconds per decision request", "float", {"min": 0.2, "max": 60}),
|
|
64
|
+
]
|
|
65
|
+
BY_KEY = {s[0]: s for s in SETTINGS}
|
|
66
|
+
SECTIONS = list(dict.fromkeys(s[1] for s in SETTINGS))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get(cfg: Dict[str, Any], key: str) -> Any:
|
|
70
|
+
node: Any = cfg
|
|
71
|
+
for part in key.split("."):
|
|
72
|
+
node = node.get(part) if isinstance(node, dict) else None
|
|
73
|
+
return node
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _nested(key: str, value: Any) -> Dict[str, Any]:
|
|
77
|
+
out: Dict[str, Any] = {}
|
|
78
|
+
node = out
|
|
79
|
+
parts = key.split(".")
|
|
80
|
+
for part in parts[:-1]:
|
|
81
|
+
node = node.setdefault(part, {})
|
|
82
|
+
node[parts[-1]] = value
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse(key: str, raw: Any) -> Any:
|
|
87
|
+
"""``raw`` (text or a value) as the setting's type; ValueError if it can't be."""
|
|
88
|
+
setting = BY_KEY.get(key)
|
|
89
|
+
if setting is None:
|
|
90
|
+
raise ValueError(f"unknown setting {key!r}; see: subcortex config show")
|
|
91
|
+
kind, options = setting[4], setting[5]
|
|
92
|
+
text = raw.strip() if isinstance(raw, str) else raw
|
|
93
|
+
if kind == "bool":
|
|
94
|
+
if isinstance(text, bool):
|
|
95
|
+
return text
|
|
96
|
+
lowered = str(text).lower()
|
|
97
|
+
if lowered in ("1", "true", "yes", "on"):
|
|
98
|
+
return True
|
|
99
|
+
if lowered in ("0", "false", "no", "off"):
|
|
100
|
+
return False
|
|
101
|
+
raise ValueError(f"{key} takes true/false")
|
|
102
|
+
if kind in ("int", "float"):
|
|
103
|
+
try:
|
|
104
|
+
value = int(text) if kind == "int" else float(text)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
raise ValueError(f"{key} takes {'a whole number' if kind == 'int' else 'a number'}") from None
|
|
107
|
+
if not options["min"] <= value <= options["max"]:
|
|
108
|
+
raise ValueError(f"{key} must be between {options['min']:g} and {options['max']:g}")
|
|
109
|
+
return value
|
|
110
|
+
if kind == "prob":
|
|
111
|
+
if text in (None, "", "null", "none", "calibrated"):
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
value = float(text)
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
raise ValueError(f"{key} is a probability between 0 and 1, or empty for the calibrated rule") from None
|
|
117
|
+
if not 0.0 <= value <= 1.0:
|
|
118
|
+
raise ValueError(f"{key} is a probability between 0 and 1")
|
|
119
|
+
return value
|
|
120
|
+
if kind == "choice":
|
|
121
|
+
allowed = [c[0] for c in options["choices"]]
|
|
122
|
+
if text in allowed or (options.get("other") and isinstance(text, str) and text):
|
|
123
|
+
return text
|
|
124
|
+
raise ValueError(f"{key} is one of: {', '.join(allowed)}")
|
|
125
|
+
if kind == "url":
|
|
126
|
+
from .backends import jev as jev_backend
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
jev_backend._check_url(jev_backend._join_url(str(text), "/v1/systemone"))
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise ValueError(f"{key}: {exc}") from None
|
|
132
|
+
return str(text).rstrip("/")
|
|
133
|
+
if kind == "name":
|
|
134
|
+
if not text or not str(text).replace("_", "").isalnum():
|
|
135
|
+
raise ValueError(f"{key}: letters, digits and _ only")
|
|
136
|
+
return str(text)
|
|
137
|
+
raise ValueError(f"{key}: unsupported")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def env_override(key: str) -> Optional[str]:
|
|
141
|
+
"""The SUBCORTEX_* variable currently forcing this setting, if any."""
|
|
142
|
+
for env, (section, name, _) in _ENV_OVERRIDES.items():
|
|
143
|
+
if (f"{section}.{name}" if section else name) == key and os.environ.get(env, "").strip():
|
|
144
|
+
return env
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def saved(key: str) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
data = json.loads(config_path().read_text())
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
node: Any = data
|
|
154
|
+
for part in key.split("."):
|
|
155
|
+
if not isinstance(node, dict) or part not in node:
|
|
156
|
+
return False
|
|
157
|
+
node = node[part]
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def shown(key: str, value: Any) -> str:
|
|
162
|
+
kind = BY_KEY[key][4]
|
|
163
|
+
if kind == "bool":
|
|
164
|
+
return "on" if value else "off"
|
|
165
|
+
if kind == "prob":
|
|
166
|
+
return "calibrated" if value is None else f"{value:g}"
|
|
167
|
+
if isinstance(value, float):
|
|
168
|
+
return f"{value:g}"
|
|
169
|
+
return str(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# -- the interactive editor -----------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Editor:
|
|
176
|
+
def __init__(self, ui: Any) -> None:
|
|
177
|
+
self.ui = ui
|
|
178
|
+
self.changed: List[str] = []
|
|
179
|
+
|
|
180
|
+
def run(self) -> int:
|
|
181
|
+
from .ui import Cancelled
|
|
182
|
+
|
|
183
|
+
ui = self.ui
|
|
184
|
+
ui.title("subcortex settings")
|
|
185
|
+
ui.dim(f"{config_path()} · changes are saved at once; the daemon picks them up by itself")
|
|
186
|
+
cursor = 0
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
cfg = load_config()
|
|
190
|
+
options = [(section, section, self._summary(cfg, section)) for section in SECTIONS]
|
|
191
|
+
options.insert(1, ("key", "Jev API key", self._key_summary(cfg)))
|
|
192
|
+
options.append((None, "Done", ""))
|
|
193
|
+
section = ui.choose("Which settings?", options, cursor)
|
|
194
|
+
if section is None:
|
|
195
|
+
break
|
|
196
|
+
cursor = next(i for i, o in enumerate(options) if o[0] == section)
|
|
197
|
+
if section == "key":
|
|
198
|
+
self.edit_key()
|
|
199
|
+
else:
|
|
200
|
+
self.edit_section(section)
|
|
201
|
+
except Cancelled:
|
|
202
|
+
ui.write()
|
|
203
|
+
self.after()
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
def _summary(self, cfg: Dict[str, Any], section: str) -> str:
|
|
207
|
+
parts = []
|
|
208
|
+
for key, sec, label, _, _, _ in SETTINGS:
|
|
209
|
+
if sec == section:
|
|
210
|
+
parts.append(f"{label.lower()} {shown(key, _get(cfg, key))}")
|
|
211
|
+
text = " · ".join(parts)
|
|
212
|
+
return text if len(text) <= 70 else text[:69] + "…"
|
|
213
|
+
|
|
214
|
+
def _key_summary(self, cfg: Dict[str, Any]) -> str:
|
|
215
|
+
from .config import read_secret
|
|
216
|
+
|
|
217
|
+
env_name = cfg["jev"]["api_key_env"]
|
|
218
|
+
if os.environ.get(env_name, "").strip():
|
|
219
|
+
return f"from ${env_name}"
|
|
220
|
+
stored = read_secret(env_name)
|
|
221
|
+
return f"stored ({stored[:8]}…{stored[-4:]})" if stored and len(stored) > 16 else "not set"
|
|
222
|
+
|
|
223
|
+
def edit_section(self, section: str) -> None:
|
|
224
|
+
ui = self.ui
|
|
225
|
+
cursor = 0
|
|
226
|
+
while True:
|
|
227
|
+
cfg = load_config()
|
|
228
|
+
options = []
|
|
229
|
+
for key, sec, label, help_text, kind, _ in SETTINGS:
|
|
230
|
+
if sec != section:
|
|
231
|
+
continue
|
|
232
|
+
forced = env_override(key)
|
|
233
|
+
state = f"forced by ${forced}" if forced else ("changed" if saved(key) else "default")
|
|
234
|
+
options.append((key, f"{label}: {shown(key, _get(cfg, key))}", f"{state} · {help_text}"))
|
|
235
|
+
options.append((None, "Back", ""))
|
|
236
|
+
key = ui.choose(f"{section} — change which? (on/off settings toggle)", options, cursor)
|
|
237
|
+
if key is None:
|
|
238
|
+
return
|
|
239
|
+
cursor = next(i for i, o in enumerate(options) if o[0] == key)
|
|
240
|
+
self.edit(key)
|
|
241
|
+
|
|
242
|
+
def edit(self, key: str) -> None:
|
|
243
|
+
ui = self.ui
|
|
244
|
+
_, _, label, help_text, kind, options = BY_KEY[key]
|
|
245
|
+
cfg = load_config()
|
|
246
|
+
current = _get(cfg, key)
|
|
247
|
+
default = _get(DEFAULT_CONFIG, key)
|
|
248
|
+
forced = env_override(key)
|
|
249
|
+
if forced:
|
|
250
|
+
ui.warn(f"${forced} is set, so it wins over the config file until you unset it")
|
|
251
|
+
if kind == "bool":
|
|
252
|
+
value: Any = not current # toggles in place
|
|
253
|
+
elif kind == "choice":
|
|
254
|
+
choices = list(options["choices"])
|
|
255
|
+
if options.get("other"):
|
|
256
|
+
choices.append(("__other__", "Another value…", ""))
|
|
257
|
+
index = next((i for i, c in enumerate(choices) if c[0] == current), len(choices) - 1)
|
|
258
|
+
value = ui.choose(f"{label}?", choices, index)
|
|
259
|
+
if value == "__other__":
|
|
260
|
+
value = ui.ask(label, str(current), validate=lambda v: _problem(key, v))
|
|
261
|
+
elif kind == "prob":
|
|
262
|
+
pick = ui.choose(f"{label}?", [("calibrated", "Calibrated for the backend", "recommended"),
|
|
263
|
+
("custom", "A value of my own", "0 to 1 · check it with subcortex eval")],
|
|
264
|
+
0 if current is None else 1)
|
|
265
|
+
value = None if pick == "calibrated" else ui.ask(
|
|
266
|
+
f"{label} (0 to 1)", "" if current is None else f"{current:g}", validate=lambda v: _problem(key, v))
|
|
267
|
+
else:
|
|
268
|
+
value = ui.ask(f"{label} ({help_text})", shown(key, current), validate=lambda v: _problem(key, v))
|
|
269
|
+
value = parse(key, value)
|
|
270
|
+
if key == "thresholds.output_needed_threshold" and value is not None and cfg.get("backend") == "laya":
|
|
271
|
+
ui.warn("With Laya this enables trimming, which cut needed output on held-out examples.")
|
|
272
|
+
if not ui.confirm("Keep this value?", False):
|
|
273
|
+
return
|
|
274
|
+
if value == default and saved(key):
|
|
275
|
+
unset_config(key) # back to the default: drop it from the file
|
|
276
|
+
elif value != current or not saved(key):
|
|
277
|
+
save_config(_nested(key, value))
|
|
278
|
+
if value != current:
|
|
279
|
+
self.changed.append(key)
|
|
280
|
+
ui.ok(f"{label}: {shown(key, value)}")
|
|
281
|
+
|
|
282
|
+
def edit_key(self) -> None:
|
|
283
|
+
from .config import delete_secret, read_secret, save_secret
|
|
284
|
+
|
|
285
|
+
ui = self.ui
|
|
286
|
+
cfg = load_config()
|
|
287
|
+
env_name = cfg["jev"]["api_key_env"]
|
|
288
|
+
if os.environ.get(env_name, "").strip():
|
|
289
|
+
ui.info(f"the key comes from ${env_name} in your environment; change it there")
|
|
290
|
+
return
|
|
291
|
+
stored = read_secret(env_name)
|
|
292
|
+
actions = [("replace", "Enter a new key", "checked with one real decision before it's saved")]
|
|
293
|
+
if stored:
|
|
294
|
+
actions.append(("remove", "Remove the stored key", "Jev decisions stop until a key is added"))
|
|
295
|
+
actions.append((None, "Back", ""))
|
|
296
|
+
action = ui.choose("Jev API key", actions, 0)
|
|
297
|
+
if action == "remove":
|
|
298
|
+
if ui.confirm("Remove the stored key?", False):
|
|
299
|
+
delete_secret(env_name)
|
|
300
|
+
ui.ok("removed")
|
|
301
|
+
elif action == "replace":
|
|
302
|
+
from .wizard import Wizard, _key_problem
|
|
303
|
+
|
|
304
|
+
key = ui.ask("API key (paste it; input is hidden)", secret=True, validate=_key_problem)
|
|
305
|
+
if not key:
|
|
306
|
+
return
|
|
307
|
+
import argparse
|
|
308
|
+
|
|
309
|
+
ok, message = Wizard(ui, argparse.Namespace(yes=False)).check_jev(key)
|
|
310
|
+
(ui.ok if ok else ui.error)(message)
|
|
311
|
+
if ok or ui.confirm("Save it anyway?", False):
|
|
312
|
+
ui.ok(f"stored in {save_secret(env_name, key)} (only you can read it)")
|
|
313
|
+
|
|
314
|
+
def after(self) -> None:
|
|
315
|
+
"""Follow-ups some changes need."""
|
|
316
|
+
ui = self.ui
|
|
317
|
+
if "port" in self.changed:
|
|
318
|
+
from . import installers
|
|
319
|
+
|
|
320
|
+
plugins = [installers.get_installer(n).display_name for n in installers.names()
|
|
321
|
+
if installers.get_installer(n).seam == "plugin"
|
|
322
|
+
and installers.get_installer(n).status()["installed"]]
|
|
323
|
+
ui.info("the daemon moves to the new port the next time it starts: subcortex serve --stop")
|
|
324
|
+
if plugins:
|
|
325
|
+
ui.info(f"plugins carry the port: run subcortex install for {', '.join(plugins)}")
|
|
326
|
+
if self.changed:
|
|
327
|
+
ui.dim("check the effect of threshold changes with: subcortex eval")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _problem(key: str, value: Any) -> Optional[str]:
|
|
331
|
+
try:
|
|
332
|
+
parse(key, value)
|
|
333
|
+
except ValueError as exc:
|
|
334
|
+
return str(exc)
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def edit(ui: Any = None) -> int:
|
|
339
|
+
from .ui import UI
|
|
340
|
+
|
|
341
|
+
return Editor(ui or UI()).run()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def known(keys: Sequence[str]) -> List[str]:
|
|
345
|
+
return [k for k in keys if k in BY_KEY]
|