@agxnte/reidx 2.0.2

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 (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. package/src/reidx/workflows/store.py +56 -0
@@ -0,0 +1,663 @@
1
+ """Slash command routing for the REPL.
2
+
3
+ Each command returns a string hint for the REPL loop:
4
+ "continue" -> keep the loop running
5
+ "exit" -> stop the loop
6
+ Commands mutate orchestrator/state in place. Add new commands here — and add
7
+ a matching entry to SLASH_COMMANDS (or WORKFLOW_SUBCOMMANDS) below, which is
8
+ the single source both /help and the "/" completion menu (ui/app.py) render
9
+ from, so they can't drift out of sync.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from rich.console import Group
16
+ from rich.panel import Panel
17
+ from rich.text import Text
18
+
19
+ from reidx.goals.models import Goal, GoalNodeKind, GoalStatus
20
+ from reidx.policy.models import PermissionMode
21
+ from reidx.provider.store import SUPPORTED_KINDS, ProviderRecord, ProviderStore, build_provider
22
+ from reidx.runtime.orchestrator import Orchestrator
23
+ from reidx.ui import render
24
+ from reidx.ui.theme import APP_NAME, BOX, PRIMARY
25
+ from reidx.workflows.models import Workflow
26
+
27
+ _EFFORT_LEVELS = ("low", "medium", "high", "xhigh")
28
+
29
+ # /recap and /review don't do their own work here — they expand into a normal
30
+ # user turn (see ui.app's handling of the "recap-run"/"review-run:<pr>"
31
+ # outcomes below) so they get the full existing turn pipeline for free:
32
+ # conversation history already in state.messages, the thinking spinner, and
33
+ # (for /review) the same policy-gated run_command tool call as any other
34
+ # shell command, prompting for approval in balanced/strict mode exactly like
35
+ # a user-typed `gh pr diff ...` would.
36
+ RECAP_PROMPT = (
37
+ "Give a one-line recap of what we've accomplished in this session so far. "
38
+ "Respond with exactly one line, no preamble."
39
+ )
40
+
41
+
42
+ def review_prompt(pr: str) -> str:
43
+ return (
44
+ f"Review GitHub PR {pr}. Use run_command to fetch context: `gh pr view {pr}` for the "
45
+ f"description and `gh pr diff {pr}` for the diff (requires the gh CLI installed and "
46
+ "authenticated). Then critique the diff for correctness bugs first, then reuse/"
47
+ "simplification/efficiency issues, most severe first. For your own uncommitted working "
48
+ "diff instead of a remote PR, just ask directly — this command is for a PR already "
49
+ "pushed to GitHub."
50
+ )
51
+
52
+ # (command, args-hint, description, help-group). Order here is display order.
53
+ SLASH_COMMANDS: list[tuple[str, str, str, str]] = [
54
+ ("/status", "", "show current session + mode + tasks", "Session"),
55
+ ("/sessions", "", "list all sessions", "Session"),
56
+ ("/resume", "<id>", "resume a prior session", "Session"),
57
+ ("/transcript", "[n]", "show last n messages (default 20)", "Session"),
58
+ ("/rewind", "", "drop the last turn from state", "Session"),
59
+ ("/rename", "<title>", "rename the current session", "Session"),
60
+ ("/recap", "", "generate a one-line session recap now", "Session"),
61
+ ("/review", "<pr>", "review a GitHub pull request via gh + the agent (for your working diff, just ask directly)", "Session"),
62
+ ("/tasks", "[status]", "list tasks (filter: pending|active|completed|failed|blocked)", "Tasks"),
63
+ ("/goal", "<subcommand> ...", "manage session goals (type '/goal ' for subcommands)", "Goals"),
64
+ ("/model", "<name>", "set model for the session", "Config & Policy"),
65
+ ("/effort", "<level>", "set reasoning effort (low|medium|high|xhigh)", "Config & Policy"),
66
+ ("/mode", "<mode>", "set permission mode (strict|balanced|autonomous|custom)", "Config & Policy"),
67
+ ("/nyx", "[on|off]", "toggle Nyx redteam/offensive-security persona", "Config & Policy"),
68
+ ("/permissions", "", "show current policy + gates", "Config & Policy"),
69
+ ("/tools", "", "list registered tools with risk levels", "Config & Policy"),
70
+ ("/workflows", "", "list saved workflows", "Workflows"),
71
+ ("/workflow", "<run|save|show|delete> ...", "manage saved workflows", "Workflows"),
72
+ ("/providers", "", "list registered providers (stub is always default)", "Providers"),
73
+ ("/connect", "", "open the interactive provider connection palette", "Providers"),
74
+ ("/disconnect", "<name>", "remove a saved provider", "Providers"),
75
+ ("/use", "<name>", "switch this session to a registered provider", "Providers"),
76
+ ("/help", "", "show this help", "Meta"),
77
+ ("/clear", "", "clear the screen", "Meta"),
78
+ ("/exit", "", f"quit {APP_NAME}", "Meta"),
79
+ ]
80
+
81
+ # (subcommand, args-hint, description) for "/workflow <subcommand>".
82
+ WORKFLOW_SUBCOMMANDS: list[tuple[str, str, str]] = [
83
+ ("run", "<name>", "run a workflow's steps in sequence"),
84
+ ("save", "<name> [n]", "save the last n user turns as a workflow (default 5)"),
85
+ ("show", "<name>", "show a workflow's steps"),
86
+ ("delete", "<name>", "delete a workflow"),
87
+ ]
88
+
89
+ GOAL_SUBCOMMANDS: list[tuple[str, str, str]] = [
90
+ ("new", "<title>", "create and activate a goal"),
91
+ ("list", "", "list goals in this session"),
92
+ ("show", "[id]", "show the active goal or a goal by id"),
93
+ ("active", "<id|clear>", "switch or clear the active goal"),
94
+ ("outcome", "<text>", "set the active goal outcome"),
95
+ ("evidence", "<add|done> ...", "manage active goal evidence"),
96
+ ("add", "<title>", "add a child subgoal to the active goal"),
97
+ ("milestone", "<title>", "add a milestone to the active goal"),
98
+ ("done", "[id] [note]", "mark a goal or node completed"),
99
+ ("block", "[id] <reason>", "mark a goal or node blocked"),
100
+ ("revise", "<note>", "record a revision on the active goal"),
101
+ ("abandon", "[id] <reason>", "abandon a goal or node"),
102
+ ("delete", "<id>", "delete a goal"),
103
+ ]
104
+
105
+
106
+ # Fixed value choices for commands whose argument is a small enum. The "/"
107
+ # completion menu offers these the moment you type "/<cmd> " — the same way
108
+ # "/goal " lists its subcommands — so you never have to remember the valid
109
+ # values. (command -> list of (value, description).) Kept here beside
110
+ # SLASH_COMMANDS so the menu and the code that parses these stay in sync.
111
+ ARG_CHOICES: dict[str, list[tuple[str, str]]] = {
112
+ "/effort": [
113
+ ("low", "minimal reasoning, fastest"),
114
+ ("medium", "balanced reasoning (default)"),
115
+ ("high", "deeper reasoning, slower"),
116
+ ("xhigh", "maximum reasoning"),
117
+ ],
118
+ "/mode": [
119
+ ("strict", "confirm every action"),
120
+ ("balanced", "confirm only risky actions"),
121
+ ("autonomous", "run without confirmations"),
122
+ ("custom", "use custom policy gates"),
123
+ ],
124
+ "/nyx": [
125
+ ("on", "enable Nyx redteam/offensive-security persona"),
126
+ ("off", "disable Nyx persona"),
127
+ ],
128
+ "/tasks": [
129
+ ("pending", "show pending tasks"),
130
+ ("active", "show active tasks"),
131
+ ("completed", "show completed tasks"),
132
+ ("failed", "show failed tasks"),
133
+ ("blocked", "show blocked tasks"),
134
+ ],
135
+ }
136
+
137
+
138
+ def _build_help() -> Group:
139
+ def section(header: str, body: str) -> Text:
140
+ # Text(..., style=...) applies to just the constructor's own content
141
+ # (the header); .append() with no style keeps the body literal — this
142
+ # avoids Text.from_markup(), which would otherwise parse literal "["
143
+ # in args hints like "[n]"/"[status]" as (invalid) markup tags and
144
+ # silently swallow them.
145
+ text = Text(f"{header}\n", style="bold")
146
+ text.append(f"{body}\n")
147
+ return text
148
+
149
+ groups: dict[str, list[str]] = {}
150
+ for cmd, args, desc, group in SLASH_COMMANDS:
151
+ left = f"{cmd} {args}".rstrip()
152
+ groups.setdefault(group, []).append(f" {left:<28} {desc}")
153
+
154
+ parts = [Panel(Text(f"{APP_NAME} commands", style=f"bold {PRIMARY}"), box=BOX, border_style=PRIMARY, padding=(0, 2))]
155
+ for group, lines in groups.items():
156
+ parts.append(section(group, "\n".join(lines)))
157
+
158
+ sub_lines = "\n".join(f" /workflow {name:<8} {args:<14} {desc}" for name, args, desc in WORKFLOW_SUBCOMMANDS)
159
+ parts.append(section("Workflow subcommands", sub_lines))
160
+
161
+ goal_lines = "\n".join(f" /goal {name:<9} {args:<18} {desc}" for name, args, desc in GOAL_SUBCOMMANDS)
162
+ parts.append(section("Goal subcommands", goal_lines))
163
+
164
+ parts.append(
165
+ section(
166
+ "Tip",
167
+ " Type / to see a completion menu for every command above — Tab/↓ to select, Enter to accept.",
168
+ )
169
+ )
170
+ return Group(*parts)
171
+
172
+
173
+ HELP = _build_help()
174
+
175
+
176
+ def _set_mode(orchestrator: Orchestrator, value: str) -> bool:
177
+ try:
178
+ mode = PermissionMode(value)
179
+ except ValueError:
180
+ render.print_error(f"unknown mode: {value}")
181
+ return False
182
+ orchestrator.set_permission_mode(mode)
183
+ render.print_info(f"mode → {mode.value}")
184
+ return True
185
+
186
+
187
+ def _handle_nyx(orchestrator: Orchestrator, arg: str) -> None:
188
+ value = arg.strip().lower()
189
+ if not value:
190
+ render.print_info(f"nyx: {'on' if orchestrator.nyx_enabled else 'off'}")
191
+ return
192
+ if value not in ("on", "off"):
193
+ render.print_error("usage: /nyx [on|off]")
194
+ return
195
+ orchestrator.set_nyx(value == "on")
196
+ render.print_info(f"nyx → {value}")
197
+
198
+
199
+ def _handle_workflow(orchestrator: Orchestrator, arg: str) -> str | None:
200
+ """Handles /workflow <run|save|show|delete> ...
201
+
202
+ Returns "workflow-run:<name>" for /workflow run (the caller — ui.app's
203
+ async turn loop — is the only thing that can actually execute a
204
+ workflow's steps, since that requires awaiting each step's turn); returns
205
+ None for every other subcommand (handled fully here).
206
+ """
207
+ parts = arg.split(None, 1)
208
+ if not parts:
209
+ render.print_error("usage: /workflow <run|save|show|delete> <name> ...")
210
+ return None
211
+ sub, rest = parts[0], (parts[1] if len(parts) > 1 else "").strip()
212
+
213
+ if sub == "run":
214
+ if not rest:
215
+ render.print_error("usage: /workflow run <name>")
216
+ elif orchestrator.workflow_store.get(rest) is None:
217
+ render.print_error(f"no such workflow: {rest}")
218
+ else:
219
+ return f"workflow-run:{rest}"
220
+ return None
221
+
222
+ if sub == "show":
223
+ wf = orchestrator.workflow_store.get(rest) if rest else None
224
+ if wf is None:
225
+ render.print_error(f"no such workflow: {rest or '(missing name)'}")
226
+ else:
227
+ render.print_workflow_steps(wf)
228
+ return None
229
+
230
+ if sub == "save":
231
+ save_parts = rest.split(None, 1)
232
+ if not save_parts:
233
+ render.print_error("usage: /workflow save <name> [n] (n = last n user turns, default 5)")
234
+ return None
235
+ name = save_parts[0]
236
+ count_str = save_parts[1].strip() if len(save_parts) > 1 else ""
237
+ n = int(count_str) if count_str.isdigit() else 5
238
+ if orchestrator.state is None or not orchestrator.state.messages:
239
+ render.print_error("no turns to save yet")
240
+ return None
241
+ steps = [m.content for m in orchestrator.state.messages if m.role == "user"][-n:]
242
+ if not steps:
243
+ render.print_error("no user turns to save yet")
244
+ return None
245
+ orchestrator.workflow_store.save(Workflow(name=name, steps=steps, description=f"last {len(steps)} turn(s)"))
246
+ render.print_info(f"saved workflow '{name}' ({len(steps)} steps)")
247
+ return None
248
+
249
+ if sub == "delete":
250
+ if not rest:
251
+ render.print_error("usage: /workflow delete <name>")
252
+ elif orchestrator.workflow_store.delete(rest):
253
+ render.print_info(f"deleted workflow '{rest}'")
254
+ else:
255
+ render.print_error(f"no such workflow: {rest}")
256
+ return None
257
+
258
+ render.print_error(f"unknown /workflow subcommand: {sub} (try run|save|show|delete)")
259
+ return None
260
+
261
+
262
+ def _goal_store(orchestrator: Orchestrator):
263
+ try:
264
+ return orchestrator.goal_store()
265
+ except RuntimeError:
266
+ render.print_error("no active session")
267
+ return None
268
+
269
+
270
+ def _active_goal(store) -> Goal | None: # type: ignore[no-untyped-def]
271
+ goal = store.active()
272
+ if goal is None:
273
+ render.print_error("no active goal (try /goal new <title>)")
274
+ return goal
275
+
276
+
277
+ def _split_first(text: str) -> tuple[str, str]:
278
+ parts = text.strip().split(None, 1)
279
+ if not parts:
280
+ return "", ""
281
+ return parts[0], (parts[1] if len(parts) > 1 else "").strip()
282
+
283
+
284
+ def _resolve_goal_target(store, text: str) -> tuple[Goal | None, str | None, str]: # type: ignore[no-untyped-def]
285
+ """Resolve optional target syntax: goal id, active-goal node id, or active goal."""
286
+ token, rest = _split_first(text)
287
+ active = store.active()
288
+ if not token:
289
+ return active, None, ""
290
+
291
+ goals = {goal.id: goal for goal in store.list()}
292
+ if token in goals:
293
+ return goals[token], None, rest
294
+
295
+ if active is not None and any(node.id == token for node in active.nodes):
296
+ return active, token, rest
297
+
298
+ return active, None, text.strip()
299
+
300
+
301
+ def _handle_goal_evidence(store, arg: str) -> None: # type: ignore[no-untyped-def]
302
+ sub, rest = _split_first(arg)
303
+ goal = _active_goal(store)
304
+ if goal is None:
305
+ return
306
+ if sub == "add":
307
+ if not rest:
308
+ render.print_error("usage: /goal evidence add <text>")
309
+ return
310
+ store.add_evidence(goal.id, rest)
311
+ render.print_info("evidence added")
312
+ return
313
+ if sub == "done":
314
+ idx, note = _split_first(rest)
315
+ if not idx.isdigit():
316
+ render.print_error("usage: /goal evidence done <index> [note]")
317
+ return
318
+ updated = store.satisfy_evidence(goal.id, int(idx) - 1, note)
319
+ if updated is None:
320
+ render.print_error(f"no evidence item #{idx}")
321
+ else:
322
+ render.print_info(f"evidence #{idx} satisfied")
323
+ return
324
+ render.print_error("usage: /goal evidence <add|done> ...")
325
+
326
+
327
+ def _handle_goal(orchestrator: Orchestrator, arg: str) -> str | None:
328
+ store = _goal_store(orchestrator)
329
+ if store is None:
330
+ return None
331
+ sub, rest = _split_first(arg)
332
+ sub = sub or "show"
333
+
334
+ if sub == "new":
335
+ if not rest:
336
+ render.print_error("usage: /goal new <title>")
337
+ return None
338
+ goal = store.create(rest)
339
+ render.print_info(f"created active goal {goal.id}")
340
+ render.print_goal(goal)
341
+ return None
342
+
343
+ if sub == "list":
344
+ render.print_goals(store.list(), store.active_id())
345
+ return None
346
+
347
+ if sub == "show":
348
+ goal = store.get(rest) if rest else store.active()
349
+ if goal is None:
350
+ render.print_error(f"no such goal: {rest or '(active)'}")
351
+ else:
352
+ render.print_goal(goal)
353
+ return None
354
+
355
+ if sub == "active":
356
+ if not rest:
357
+ goal = store.active()
358
+ render.print_info(f"active goal: {goal.id} {goal.title}" if goal else "no active goal")
359
+ return None
360
+ if rest == "clear":
361
+ store.set_active(None)
362
+ render.print_info("active goal cleared")
363
+ return None
364
+ goal = store.set_active(rest)
365
+ if goal is None:
366
+ render.print_error(f"no such goal: {rest}")
367
+ else:
368
+ render.print_info(f"active goal -> {goal.id} {goal.title}")
369
+ return None
370
+
371
+ if sub == "outcome":
372
+ goal = _active_goal(store)
373
+ if goal is None:
374
+ return None
375
+ if not rest:
376
+ render.print_error("usage: /goal outcome <text>")
377
+ return None
378
+ store.set_outcome(goal.id, rest)
379
+ render.print_info("outcome updated")
380
+ return None
381
+
382
+ if sub == "evidence":
383
+ _handle_goal_evidence(store, rest)
384
+ return None
385
+
386
+ if sub in ("add", "milestone"):
387
+ goal = _active_goal(store)
388
+ if goal is None:
389
+ return None
390
+ if not rest:
391
+ render.print_error(f"usage: /goal {sub} <title>")
392
+ return None
393
+ kind = GoalNodeKind.MILESTONE if sub == "milestone" else GoalNodeKind.SUBGOAL
394
+ node = store.add_node(goal.id, rest, kind)
395
+ if node is None:
396
+ render.print_error("failed to add goal node")
397
+ else:
398
+ render.print_info(f"added {kind.value} {node.id}")
399
+ return None
400
+
401
+ if sub == "done":
402
+ goal, node_id, note = _resolve_goal_target(store, rest)
403
+ if goal is None:
404
+ render.print_error("no active goal")
405
+ return None
406
+ if node_id:
407
+ store.update_status(goal.id, GoalStatus.COMPLETED, note or "completed", node_id=node_id)
408
+ render.print_info(f"completed node {node_id}")
409
+ return None
410
+ if not goal.evidence:
411
+ render.print_error("cannot complete a goal with no evidence (add /goal evidence add <text>)")
412
+ return None
413
+ if any(not evidence.satisfied for evidence in goal.evidence):
414
+ render.print_warn("goal has unsatisfied evidence; marking completed anyway")
415
+ store.update_status(goal.id, GoalStatus.COMPLETED, note or "completed")
416
+ render.print_info(f"completed goal {goal.id}")
417
+ return None
418
+
419
+ if sub == "block":
420
+ goal, node_id, reason = _resolve_goal_target(store, rest)
421
+ if goal is None:
422
+ render.print_error("no active goal")
423
+ return None
424
+ if not reason:
425
+ render.print_error("usage: /goal block [id] <reason>")
426
+ return None
427
+ store.update_status(goal.id, GoalStatus.BLOCKED, reason, node_id=node_id)
428
+ render.print_info(f"blocked {'node ' + node_id if node_id else 'goal ' + goal.id}")
429
+ return None
430
+
431
+ if sub == "revise":
432
+ goal = _active_goal(store)
433
+ if goal is None:
434
+ return None
435
+ if not rest:
436
+ render.print_error("usage: /goal revise <note>")
437
+ return None
438
+ store.add_note(goal.id, rest)
439
+ render.print_info("revision recorded")
440
+ return None
441
+
442
+ if sub == "abandon":
443
+ goal, node_id, reason = _resolve_goal_target(store, rest)
444
+ if goal is None:
445
+ render.print_error("no active goal")
446
+ return None
447
+ if not reason:
448
+ render.print_error("usage: /goal abandon [id] <reason>")
449
+ return None
450
+ store.update_status(goal.id, GoalStatus.ABANDONED, reason, node_id=node_id)
451
+ render.print_info(f"abandoned {'node ' + node_id if node_id else 'goal ' + goal.id}")
452
+ return None
453
+
454
+ if sub == "delete":
455
+ if not rest:
456
+ render.print_error("usage: /goal delete <id>")
457
+ elif store.delete(rest):
458
+ render.print_info(f"deleted goal {rest}")
459
+ else:
460
+ render.print_error(f"no such goal: {rest}")
461
+ return None
462
+
463
+ # Natural fallback: `/goal make me a report` should create a goal, not
464
+ # force the user to remember `/goal new ...`.
465
+ goal = store.create(arg.strip())
466
+ render.print_info(f"created active goal {goal.id}")
467
+ render.print_goal(goal)
468
+ return None
469
+
470
+
471
+ _BUILTIN_PROVIDER_NAMES = ("stub", "anthropic")
472
+
473
+
474
+ def _providers_store(orchestrator: Orchestrator) -> ProviderStore:
475
+ root = orchestrator.config.storage_root or (Path.home() / ".reidx")
476
+ return ProviderStore(root)
477
+
478
+
479
+ def _handle_providers(orchestrator: Orchestrator) -> None:
480
+ store = _providers_store(orchestrator)
481
+ persisted = store.list()
482
+ persisted_names = {r.name for r in persisted}
483
+ active = orchestrator.state.session.provider if orchestrator.state else orchestrator.config.default_provider
484
+ extra: list[str] = []
485
+ if orchestrator.providers is not None:
486
+ for name in orchestrator.providers.names():
487
+ if name not in persisted_names:
488
+ extra.append(name)
489
+ render.print_providers(persisted, active, extra)
490
+
491
+
492
+ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
493
+ parts = arg.split()
494
+ if len(parts) < 3:
495
+ render.print_error(
496
+ "usage: /connect <name> <kind> <base_url> [api_key] [model] "
497
+ f"(kind: {'|'.join(SUPPORTED_KINDS)})"
498
+ )
499
+ return
500
+ name, kind, base_url = parts[0], parts[1], parts[2]
501
+ if kind not in SUPPORTED_KINDS:
502
+ render.print_error(f"unknown kind: {kind} (try {'|'.join(SUPPORTED_KINDS)})")
503
+ return
504
+ if name in _BUILTIN_PROVIDER_NAMES and kind != "anthropic":
505
+ render.print_error(f"name '{name}' is reserved for the built-in provider")
506
+ return
507
+ api_key = parts[3] if len(parts) > 3 else ""
508
+ model = parts[4] if len(parts) > 4 else ""
509
+ record = ProviderRecord(name=name, kind=kind, base_url=base_url, api_key=api_key, default_model=model)
510
+ try:
511
+ provider = build_provider(record)
512
+ except ValueError as exc:
513
+ render.print_error(f"failed to build provider: {exc}")
514
+ return
515
+ _providers_store(orchestrator).save(record)
516
+ if orchestrator.providers is not None:
517
+ orchestrator.providers.register(name, provider)
518
+ render.print_info(f"connected provider '{name}' ({kind}) → {base_url or '(default)'}")
519
+ render.print_info(f"switch with: /use {name}")
520
+
521
+
522
+ def _handle_disconnect(orchestrator: Orchestrator, arg: str) -> None:
523
+ name = arg.strip()
524
+ if not name:
525
+ render.print_error("usage: /disconnect <name>")
526
+ return
527
+ if name in _BUILTIN_PROVIDER_NAMES:
528
+ render.print_error(f"cannot disconnect built-in provider '{name}'")
529
+ return
530
+ active = orchestrator.state.session.provider if orchestrator.state else ""
531
+ if name == active:
532
+ render.print_error(f"'{name}' is active; /use stub first, then disconnect")
533
+ return
534
+ removed = _providers_store(orchestrator).delete(name)
535
+ if orchestrator.providers is not None:
536
+ orchestrator.providers.unregister(name)
537
+ if removed:
538
+ render.print_info(f"disconnected '{name}'")
539
+ else:
540
+ render.print_error(f"no saved provider named '{name}'")
541
+
542
+
543
+ def _handle_use(orchestrator: Orchestrator, arg: str) -> None:
544
+ name = arg.strip()
545
+ if not name:
546
+ render.print_error("usage: /use <name> (see /providers)")
547
+ return
548
+ if orchestrator.providers is None or not orchestrator.providers.has(name):
549
+ render.print_error(f"provider '{name}' is not registered (see /providers)")
550
+ return
551
+ if orchestrator.state is None:
552
+ render.print_error("no active session")
553
+ return
554
+ try:
555
+ orchestrator.use_provider(name)
556
+ except (KeyError, RuntimeError) as exc:
557
+ render.print_error(str(exc))
558
+ return
559
+ render.print_info(f"active provider → {name} (model: {orchestrator.state.session.model})")
560
+
561
+
562
+ def handle(orchestrator: Orchestrator, line: str) -> str:
563
+ parts = line.strip().split(None, 1)
564
+ cmd = parts[0].lstrip("/")
565
+ arg = parts[1] if len(parts) > 1 else ""
566
+
567
+ if cmd in ("help", "?"):
568
+ render.console.print(HELP)
569
+ elif cmd == "status":
570
+ if orchestrator.state:
571
+ chars = sum(len(m.content or "") for m in orchestrator.state.messages)
572
+ render.status_bar(
573
+ orchestrator.state.session,
574
+ orchestrator.state.effective_mode,
575
+ len(orchestrator.list_tasks()),
576
+ tokens_used=max(1, chars // 4),
577
+ )
578
+ else:
579
+ render.print_info("no active session")
580
+ elif cmd == "sessions":
581
+ render.print_sessions(orchestrator.session_store.list())
582
+ elif cmd == "resume":
583
+ if not arg:
584
+ render.print_error("usage: /resume <session-id>")
585
+ else:
586
+ try:
587
+ orchestrator.resume_session(arg)
588
+ count = len(orchestrator.state.messages) if orchestrator.state else 0
589
+ render.print_info(f"resumed {arg} ({count} messages restored)")
590
+ except KeyError as exc:
591
+ render.print_error(str(exc))
592
+ elif cmd == "tasks":
593
+ tasks = orchestrator.list_tasks()
594
+ if arg:
595
+ tasks = [t for t in tasks if t.status.value == arg]
596
+ render.print_tasks(tasks)
597
+ elif cmd == "goal":
598
+ _handle_goal(orchestrator, arg)
599
+ elif cmd == "transcript":
600
+ if orchestrator.state is None:
601
+ render.print_info("no active session")
602
+ else:
603
+ n = int(arg) if arg.isdigit() else 20
604
+ render.print_transcript(orchestrator.state.messages, n)
605
+ elif cmd == "model":
606
+ if not arg or orchestrator.state is None:
607
+ render.print_error("usage: /model <name> (with an active session)")
608
+ else:
609
+ orchestrator.state.session.model = arg
610
+ orchestrator.session_store.update(orchestrator.state.session)
611
+ render.print_info(f"model → {arg}")
612
+ elif cmd == "effort":
613
+ if orchestrator.state is None:
614
+ render.print_error("usage: /effort <low|medium|high|xhigh> (with an active session)")
615
+ elif not arg:
616
+ render.print_info(f"current effort: {orchestrator.state.session.reasoning_effort}")
617
+ elif arg not in _EFFORT_LEVELS:
618
+ render.print_error(f"unknown effort: {arg} (try low|medium|high|xhigh)")
619
+ else:
620
+ orchestrator.state.session.reasoning_effort = arg
621
+ orchestrator.session_store.update(orchestrator.state.session)
622
+ render.print_info(f"effort → {arg}")
623
+ elif cmd == "mode":
624
+ if not arg:
625
+ render.print_info(f"current mode: {orchestrator.policy.mode.value}")
626
+ else:
627
+ _set_mode(orchestrator, arg)
628
+ elif cmd == "nyx":
629
+ _handle_nyx(orchestrator, arg)
630
+ elif cmd == "permissions":
631
+ render.print_permissions(orchestrator.policy)
632
+ elif cmd == "tools":
633
+ render.print_tools(orchestrator.tools.definitions())
634
+ elif cmd == "rewind":
635
+ if orchestrator.state is None or not orchestrator.state.messages:
636
+ render.print_info("nothing to rewind")
637
+ else:
638
+ orchestrator.rewind()
639
+ render.print_info(f"rewound to {len(orchestrator.state.messages)} messages")
640
+ elif cmd == "workflows":
641
+ render.print_workflows(orchestrator.workflow_store.list())
642
+ elif cmd == "workflow":
643
+ outcome = _handle_workflow(orchestrator, arg)
644
+ if outcome is not None:
645
+ return outcome
646
+ elif cmd == "providers":
647
+ _handle_providers(orchestrator)
648
+ elif cmd == "connect":
649
+ if arg.strip():
650
+ _handle_connect(orchestrator, arg)
651
+ else:
652
+ return "connect"
653
+ elif cmd == "disconnect":
654
+ _handle_disconnect(orchestrator, arg)
655
+ elif cmd == "use":
656
+ _handle_use(orchestrator, arg)
657
+ elif cmd == "clear":
658
+ render.console.clear()
659
+ elif cmd in ("exit", "quit", "q"):
660
+ return "exit"
661
+ else:
662
+ render.print_error(f"unknown command: /{cmd} (try /help)")
663
+ return "continue"