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.
Files changed (93) hide show
  1. {subcortex-0.3.0 → subcortex-0.3.1}/PKG-INFO +11 -7
  2. {subcortex-0.3.0 → subcortex-0.3.1}/README.md +10 -6
  3. {subcortex-0.3.0 → subcortex-0.3.1}/pyproject.toml +1 -1
  4. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/__init__.py +1 -1
  5. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/cli.py +15 -23
  6. subcortex-0.3.1/src/subcortex/settings.py +345 -0
  7. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/wizard.py +240 -53
  8. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/PKG-INFO +11 -7
  9. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/SOURCES.txt +2 -0
  10. subcortex-0.3.1/tests/test_settings.py +120 -0
  11. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_setup.py +124 -24
  12. {subcortex-0.3.0 → subcortex-0.3.1}/setup.cfg +0 -0
  13. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/__main__.py +0 -0
  14. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/__init__.py +0 -0
  15. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/base.py +0 -0
  16. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/claude_family.py +0 -0
  17. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/codex.py +0 -0
  18. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/copilot.py +0 -0
  19. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/cursor.py +0 -0
  20. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/docker_agent.py +0 -0
  21. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/gemini_family.py +0 -0
  22. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/grok.py +0 -0
  23. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/kimi_code.py +0 -0
  24. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/letta_vibe.py +0 -0
  25. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/adapters/openhands.py +0 -0
  26. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/auth.py +0 -0
  27. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/__init__.py +0 -0
  28. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/base.py +0 -0
  29. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/jev.py +0 -0
  30. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/backends/laya.py +0 -0
  31. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/client.py +0 -0
  32. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/config.py +0 -0
  33. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/daemon.py +0 -0
  34. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/evalset.py +0 -0
  35. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/hook.py +0 -0
  36. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/__init__.py +0 -0
  37. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/amp.py +0 -0
  38. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/base.py +0 -0
  39. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/claude_family.py +0 -0
  40. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/codex.py +0 -0
  41. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/copilot.py +0 -0
  42. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/crush.py +0 -0
  43. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/cursor.py +0 -0
  44. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/gemini_family.py +0 -0
  45. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/goose.py +0 -0
  46. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/kimi_code.py +0 -0
  47. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/mcp_only.py +0 -0
  48. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/more_hooks.py +0 -0
  49. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/opencode.py +0 -0
  50. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/openhands.py +0 -0
  51. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/installers/pi_cline.py +0 -0
  52. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/ledger.py +0 -0
  53. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/localhttp.py +0 -0
  54. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/mcp_server.py +0 -0
  55. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/metrics.py +0 -0
  56. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/amp/subcortex.ts +0 -0
  57. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/cline/subcortex.ts +0 -0
  58. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/opencode/subcortex.ts +0 -0
  59. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/plugins/pi/subcortex.ts +0 -0
  60. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/policy.py +0 -0
  61. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/presets.py +0 -0
  62. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/provision.py +0 -0
  63. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/service.py +0 -0
  64. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/state.py +0 -0
  65. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/transcript.py +0 -0
  66. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/tuis.py +0 -0
  67. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/ui.py +0 -0
  68. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex/verdicts.py +0 -0
  69. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/dependency_links.txt +0 -0
  70. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/entry_points.txt +0 -0
  71. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/requires.txt +0 -0
  72. {subcortex-0.3.0 → subcortex-0.3.1}/src/subcortex.egg-info/top_level.txt +0 -0
  73. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_adapters.py +0 -0
  74. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_cli.py +0 -0
  75. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_config.py +0 -0
  76. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_daemon.py +0 -0
  77. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e.py +0 -0
  78. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_codex.py +0 -0
  79. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_gemini.py +0 -0
  80. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_more.py +0 -0
  81. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_plugins.py +0 -0
  82. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_e2e_vendor.py +0 -0
  83. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_hook_process.py +0 -0
  84. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_installer_base.py +0 -0
  85. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_installers.py +0 -0
  86. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_jev.py +0 -0
  87. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_lifecycle.py +0 -0
  88. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_mcp.py +0 -0
  89. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_plugins.py +0 -0
  90. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_policy.py +0 -0
  91. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_state.py +0 -0
  92. {subcortex-0.3.0 → subcortex-0.3.1}/tests/test_transcript.py +0 -0
  93. {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.0
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** — Laya (local; installs `laya-mlx`/`laya` into a dedicated
101
- environment and downloads the model) or Jev (hosted; your API key is kept in
102
- the environment or in a mode-600 file).
103
- 2. **Behaviors** prompt hints, output trimming, compaction snapshots.
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 # show every setting (config edit: interactive)
212
- subcortex config set thresholds.min_output_chars 8000
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** — Laya (local; installs `laya-mlx`/`laya` into a dedicated
90
- environment and downloads the model) or Jev (hosted; your API key is kept in
91
- the environment or in a mode-600 file).
92
- 2. **Behaviors** prompt hints, output trimming, compaction snapshots.
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 # show every setting (config edit: interactive)
201
- subcortex config set thresholds.min_output_chars 8000
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.0"
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"
@@ -1,3 +1,3 @@
1
1
  """subcortex — a local decision layer for coding-agent TUIs."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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
- action = args.action or "show"
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 = _coerce(args.key, args.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("config edit needs a terminal; use: subcortex config set <key> <value>", file=sys.stderr)
663
+ print("the settings editor needs a terminal; use: subcortex config set <key> <value>", file=sys.stderr)
659
664
  return 2
660
- try:
661
- while True:
662
- flat = _flatten(load_config())
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="show or change settings")
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]