@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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- 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"
|