pyrtan / sourcev0.13.0 · main

Initial public release

Claude · 2026-10-25 · 34229d790bee138d4aa5993a70b2158cd8802972

Changes

 .dockerignore                              |  12 +
 .gitignore                                 |   9 +
 CONTRIBUTING.md                            |  44 ++++
 Dockerfile                                 |  29 +++
 LICENSE                                    | 661 +++++++++++++++++++++++++++++++++++++++++++++++++
 README.md                                  | 190 ++++++++++++++
 _meta/design.md                            | 254 +++++++++++++++++++
 _meta/tree-format.md                       | 230 +++++++++++++++++
 deploy/README.md                           | 115 +++++++++
 deploy/openmediavault/docker-compose.yml   |  58 +++++
 deploy/systemd/pyrtan-demo.service         |  38 +++
 examples/github/github-release.toml        |  62 +++++
 examples/hacker-news/hacker-news-item.toml |  65 +++++
 examples/hacker-news/hacker-news.toml      |  78 ++++++
 examples/site-status/site-status.toml      |  61 +++++
 examples/weather/weather.toml              | 128 ++++++++++
 pyproject.toml                             |  42 ++++
 src/pyrtan/__init__.py                     |  34 +++
 src/pyrtan/cli.py                          | 337 +++++++++++++++++++++++++
 src/pyrtan/conditions.py                   | 144 +++++++++++
 src/pyrtan/extract.py                      | 120 +++++++++
 src/pyrtan/html.py                         | 253 +++++++++++++++++++
 src/pyrtan/hub.py                          | 264 ++++++++++++++++++++
 src/pyrtan/intent.py                       | 164 ++++++++++++
 src/pyrtan/page.py                         | 128 ++++++++++
 src/pyrtan/registry.py                     | 158 ++++++++++++
 src/pyrtan/runtime.py                      | 409 ++++++++++++++++++++++++++++++
 src/pyrtan/service.py                      | 262 ++++++++++++++++++++
 src/pyrtan/template.py                     | 170 +++++++++++++
 src/pyrtan/trace.py                        |  74 ++++++
 src/pyrtan/transport.py                    | 146 +++++++++++
 src/pyrtan/tree.py                         | 565 ++++++++++++++++++++++++++++++++++++++++++
 tests/conftest.py                          |  26 ++
 tests/fixtures/examples.json               |  17 ++
 tests/test_cli.py                          |  66 +++++
 tests/test_conditions.py                   |  60 +++++
 tests/test_examples.py                     |  83 +++++++
 tests/test_extract.py                      |  51 ++++
 tests/test_html.py                         |  68 +++++
 tests/test_intent.py                       |  59 +++++
 tests/test_runtime.py                      | 257 +++++++++++++++++++
 tests/test_service_hub.py                  | 213 ++++++++++++++++
 tests/test_template.py                     |  55 ++++
 tests/test_tree.py                         | 148 +++++++++++
 uv.lock                                    | 193 +++++++++++++++
 45 files changed, 6600 insertions(+)
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d1c438b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.venv
+.pytest_cache
+__pycache__
+*.pyc
+dist
+build
+tests
+deploy
+_meta
+.env
+.env.*
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af4a18 --- /dev/null +++ b/.gitignore
@@ -0,0 +1,9 @@
+.venv/
+__pycache__/
+*.pyc
+.pytest_cache/
+dist/
+build/
+*.egg-info/
+.env
+.env.*
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ffac9e0 --- /dev/null +++ b/CONTRIBUTING.md
@@ -0,0 +1,44 @@
+# Contributing
+
+Pyrtan grows mostly by trees, and a tree is a text file. If a tree you want
+does not exist, write it; if one gives a wrong answer, the trace shows where.
+
+## Adding or fixing a tree
+
+1. `pyrtan new my-tree --dir examples/my-tree` and edit the file. Keep the
+ `hosts` list to exactly the hosts it needs.
+2. `pyrtan check examples/my-tree` until it is clean. Warnings are worth
+ reading; an `info` about a `decide` without `else` is a choice, not a
+ fault.
+3. Record responses for the tests:
+ `pyrtan run "the sentence" --trees examples --record tests/fixtures/my-tree.json`.
+ Trim the recording to what the test needs, and never commit anything
+ with a credential in it.
+4. Add a test in `tests/test_examples.py` that runs the sentence against the
+ recording and asserts the `say`, the `output`, and the branches taken.
+5. `uv run pytest`.
+
+A tree that reads a page should prefer an API or a feed when the site offers
+one, and should decide on the status code before it decides on the body.
+
+## Changing the runtime
+
+The runtime is small on purpose, and every node kind is paid for by every
+tree author who has to learn it. A new kind needs a case that `observe`,
+`decide`, `set`, `each` and `delegate` cannot express together, written down
+in the pull request. The same goes for a new filter or condition.
+
+Nothing in `src/pyrtan` may call a model, execute code from a tree, or reach
+a host outside a tree's `hosts`. Those are the three rules the design rests
+on; see `_meta/design.md`.
+
+## License of contributions
+
+Pyrtan is AGPL-3.0-or-later. By opening a pull request you agree that your
+contribution is licensed under the same terms.
+
+## Style
+
+Python 3.12+, standard library only at run time. Tests are pytest. A
+docstring at the top of each module says what it is for and what it refuses.
+Commit messages say what changed and why, in the imperative.
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6889a90 --- /dev/null +++ b/Dockerfile
@@ -0,0 +1,29 @@
+# Pyrtan: one image for a service or a hub. The command decides which.
+#
+# docker run -p 8701:8701 -v ./trees:/trees pyrtan serve /trees --host 0.0.0.0
+# docker run -p 8700:8700 pyrtan hub --host 0.0.0.0
+#
+# The examples are baked in at /examples so the image demos on its own.
+FROM python:3.12-slim AS build
+WORKDIR /src
+COPY pyproject.toml README.md LICENSE ./
+COPY src ./src
+RUN pip install --no-cache-dir --prefix=/install .
+
+FROM python:3.12-slim
+LABEL org.opencontainers.image.title="pyrtan" \
+ org.opencontainers.image.description="A swarm of small services that act on the web through hardcoded, inspectable decision trees." \
+ org.opencontainers.image.source="https://github.com/b-i-t-8/pyrtan" \
+ org.opencontainers.image.licenses="AGPL-3.0-or-later"
+COPY --from=build /install /usr/local
+COPY examples /examples
+RUN useradd --system --uid 10001 --home /nonexistent --shell /usr/sbin/nologin pyrtan \
+ && mkdir -p /trees && chown pyrtan:pyrtan /trees
+USER pyrtan
+VOLUME ["/trees"]
+ENV PYTHONUNBUFFERED=1 PYRTAN_TREES=/trees
+EXPOSE 8700 8701
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD python -c "import os,sys,urllib.request;p=os.environ.get('PYRTAN_PORT','8701');sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{p}/health',timeout=4).status==200 else 1)"
+ENTRYPOINT ["pyrtan"]
+CMD ["serve", "/examples", "--host", "0.0.0.0", "--port", "8701"]
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..d71a881 --- /dev/null +++ b/README.md
@@ -0,0 +1,190 @@
+# Pyrtan
+
+**Open-source total automation: a swarm of small services that act on the web
+through hardcoded, inspectable decision trees.**
+
+Pyrtan looks like an assistant from the outside. You give it a sentence; it
+does something on the web and tells you what happened. Inside there is no
+model. Every service is a set of **trees**: declarative files that say which
+sentences they answer to, which hosts they may touch, what they observe, and
+exactly how they decide what to do next. Every run produces a **trace** that
+names each observation and the condition that chose each branch.
+
+That is the trade. A model generalises to sentences nobody wrote a tree for;
+Pyrtan does not, and says so. In return, Pyrtan is deterministic, costs
+nothing per run, works offline against recorded responses, cannot reach a
+host its author did not list, and can always explain itself.
+
+Trees are close in spirit to Claude's skills, with two differences. They are
+much narrower: a tree does one thing against one web property. And they are
+live: a service reloads a tree the moment its file changes, a tree can hand
+part of its work to any other tree in the swarm, and every decision that
+meets a value no branch covers is recorded as a **gap**, which is how a tree
+tells its author where to grow next.
+
+```
+$ pyrtan run "what's the weather in Oslo" --trees examples
+trace: weather
+ geocode (observe): GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&… -> 200; hit={…}, lat=59.91, lon=10.75, name=Oslo, country=Norway -> found
+ found (decide) [exists True]: state.hit = {…} -> forecast
+ forecast (observe): GET https://api.open-meteo.com/v1/forecast?latitude=59.91&… -> 200; temp=4.2, humidity=80, wind=12.3, code=61, unit=°C -> have-temp
+ have-temp (decide) [type 'number']: state.temp = 4.2 -> sky
+ sky (decide) [between [51, 67]]: state.code = 61 -> rain
+ rain (set): sky=raining -> reply
+ reply (done): Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.
+
+Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.
+```
+
+## Install
+
+Python 3.12 or later. No runtime dependencies. Not yet on PyPI; install
+from the repository:
+
+```
+uv tool install git+https://github.com/b-i-t-8/pyrtan # or: pip install git+https://github.com/b-i-t-8/pyrtan
+pyrtan --version
+```
+
+From a checkout, `uv sync` then `uv run pyrtan …`, and `uv run pytest`.
+
+## A tree
+
+```toml
+name = "github-release"
+description = "The latest release of a GitHub repository."
+hosts = ["api.github.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest release of {repo:word}"
+
+[inputs.repo]
+type = "str"
+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}/releases/latest" }
+into = "resp"
+extract = [{ into = "tag", json = "json.tag_name" }, { into = "url", json = "json.html_url" }]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "none" },
+ { when = { in = [403, 429] }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.repo }}: {{ state.tag }} {{ state.url }}"
+output = { repo = "{{ inputs.repo }}", tag = "{{ state.tag }}", url = "{{ state.url }}" }
+
+[nodes.none]
+kind = "fail"
+reason = "no-release"
+say = "{{ inputs.repo }} has no releases, or does not exist."
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "GitHub is rate-limiting unauthenticated requests."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "GitHub answered {{ state.resp.status }}."
+```
+
+Node kinds: `observe` (read; retried), `act` (change; never retried),
+`decide` (one value, plain conditions, one branch), `set`, `each` (walk a
+list with a limit, through another tree or a local sub-flow), `delegate`
+(hand a sentence or a tree name to the swarm), `ask` (pause for an input),
+`done` and `fail`. Trees do not loop; `pyrtan check` refuses a cycle. The
+whole format is in [`_meta/tree-format.md`](_meta/tree-format.md).
+
+## The command
+
+| Command | What it does |
+| :-- | :-- |
+| `pyrtan check DIR` | Validates every tree: references, conditions, selectors, hosts, templates, reachability, cycles. Exit 1 on any error. |
+| `pyrtan list DIR` | What is loaded and the sentences it answers to. |
+| `pyrtan run "…" --trees DIR` | Answers a sentence in this process, printing the trace. `--replay FIXTURE` answers HTTP from a recording; `--record FIXTURE` makes one. |
+| `pyrtan run --tree NAME --input k=v` | Runs a named tree. |
+| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`. Reloads trees when their files change. |
+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. |
+| `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |
+| `pyrtan gaps --at URL` | Every decision that met a value with no branch: the list of what to write next. |
+| `pyrtan new NAME` | Scaffolds a tree file. |
+
+## A swarm
+
+```
+pyrtan hub --port 8700
+pyrtan serve examples/weather --port 8701 --hub http://127.0.0.1:8700
+pyrtan serve examples/hacker-news --port 8702 --hub http://127.0.0.1:8700
+pyrtan ask "top 3 stories on hacker news"
+```
+
+Open <http://127.0.0.1:8700/> for the demo page: a sentence box, the
+answer, the output and the trace, and everything the swarm can answer. To
+run the demo on a NAS or any Docker host, see [`deploy/README.md`](deploy/README.md).
+
+A service and a hub answer the same requests, so a caller never needs to
+know which it is talking to. A swarm starts as one process holding every
+tree and splits into many without the callers changing. A tree that
+`delegate`s a sentence it cannot answer itself sends it to its hub, which
+routes it to whichever peer can; the peer's steps come back inside the
+caller's trace.
+
+## What Pyrtan refuses
+
+- **No model in the loop.** Nothing in this repository calls an LLM. A model
+ may one day sit *beside* the swarm, proposing branches for recorded gaps;
+ it will not sit inside a run deciding anything.
+- **No host a tree did not name.** Every HTTP node's host is checked against
+ the tree's `hosts` at load time where it is literal and at run time where
+ it is templated.
+- **No code in trees.** Templates are paths and filters; conditions are
+ comparisons. Branching is only ever a `decide` node, visible in the trace.
+- **No loops.** A path through a tree is finite by construction.
+- **No silent retries of actions.** An `observe` is retried; an `act` is not.
+
+## Layout
+
+```
+src/pyrtan/ the package: tree, intent, template, conditions, extract,
+ html, transport, runtime, trace, registry, service, hub, cli
+examples/ trees that run against real public APIs, and offline against
+ tests/fixtures/examples.json
+tests/ pytest; a swarm of two services behind a hub runs on localhost
+_meta/ the design record and the tree format reference
+deploy/ Dockerfile at the root; a compose stack for OpenMediaVault
+ and a systemd unit, with the steps for each
+```
+
+## Status
+
+0.1.0. The core, the service, the hub and the command are built and tested.
+Not yet built: a browser adapter (for pages that need a script to render),
+authentication between services, persistent gap storage, and a tree
+marketplace. See [`_meta/design.md`](_meta/design.md) for the sequencing.
+
+Pyrtan is a product of [Hillvon.io](https://hillvon.io).
+
+## License
+
+Pyrtan is free software under the GNU Affero General Public License,
+version 3 or (at your option) any later version: `AGPL-3.0-or-later`. The
+full text is in [LICENSE](LICENSE).
+
+In short: you may use, change and share it. If you distribute a modified
+Pyrtan, or run one as a network service that other people use, you must
+offer those users its source under the same license. Trees you write and
+run with Pyrtan are your own; they are data the program reads, not part of
+the program.
diff --git a/_meta/design.md b/_meta/design.md new file mode 100644 index 0000000..2510377 --- /dev/null +++ b/_meta/design.md
@@ -0,0 +1,254 @@
+# Design — Pyrtan: a swarm of services that act on the web through hardcoded decision trees
+
+| | |
+|---|---|
+| **Status** | **Built, v0.1.** The core, the service, the hub and the command exist and are tested (111 tests, including a two-service swarm behind a hub on localhost). No deployment, no users, no browser adapter yet. |
+| **Date** | 2026-10-24 |
+| **Decision** | Pyrtan is an open-source automation tool: many small services, each holding declarative decision trees that read and act on the web, routed by a hub, with no model in the loop and a complete trace of every run. |
+| **Where** | This repository: `src/pyrtan`, `examples/`, `tests/`. AGPL-3.0-or-later. |
+
+## 0. The brief, and the reading this design commits to
+
+The brief: *"an open source total automation tool; start over from scratch;
+a swarm of services that are AI-LLM-like but with hardcoded decision trees
+of the web, similar to Claude skills but much more focused and dynamic."*
+
+Each phrase was read as follows, and the reading is the design.
+
+**"LLM-like."** The interface, not the mechanism. A person or a program
+gives Pyrtan a sentence and gets back an answer in words, a structured
+output, and an explanation. Inside there is no model. The explanation is
+not a model's account of itself; it is the literal path through the tree.
+
+**"Hardcoded decision trees of the web."** A tree is a file an author
+writes, naming the hosts it may touch, the requests it makes, the values it
+extracts, and the comparisons that choose the next step. "Of the web" means
+the tree encodes how one web property behaves: what a 404 means here, which
+JSON path holds the title, which weather code means rain. The knowledge a
+model would hold implicitly, a tree holds explicitly, for one site.
+
+**"Swarm of services."** A tree is held by a service; a service is a small
+process that answers `/intent` and `/run`; a hub routes between services
+and reports which are up. The swarm is the unit of deployment and of
+ownership: one team can own the trees for one property, run them on their
+own box, and register with a shared hub.
+
+**"Similar to Claude skills but much more focused and dynamic."** A skill
+is a prose instruction package a model reads and interprets. A tree is
+narrower (one thing, one property, no interpretation) and livelier in three
+specific ways: a service reloads a tree when its file changes; a tree can
+delegate to any other tree in the swarm by sentence or by name, resolved at
+run time; and a decision that meets a value no branch covers is recorded as
+a *gap*, so the set of trees grows where reality disagreed with its author.
+
+**"Total automation."** Read as the ambition, not as a claim. v0.1 reads
+APIs and pages and can POST. What it does not yet do is in Section 7.
+
+## 1. What this design refuses
+
+Four things a reader might expect, and will not find.
+
+- **A model inside a run.** Nothing in `src/pyrtan` calls one. The entire
+ value of a tree is that two runs with the same observations take the same
+ path, and that the path can be read. A model anywhere in the path makes
+ the trace a story rather than a record. A model may later sit *beside*
+ the swarm, reading gaps and proposing branches for a person to accept;
+ that is an author's tool, not a runtime.
+- **Code in trees.** Templates are paths and filters. Conditions are
+ comparisons. There is no expression language, no `if` in a template, no
+ script node. Everything that branches is a `decide` node, which the trace
+ shows. The cost is verbosity (the weather tree spends seven `set` nodes
+ turning a code into a word); the benefit is that a tree can be audited by
+ someone who does not program.
+- **Reaching a host the author did not list.** `hosts` is checked at load
+ time for literal URLs and at run time for templated ones. A tree that
+ takes a host from its input must say `hosts = ["*"]` out loud.
+- **Loops.** A tree is a directed acyclic graph; `pyrtan check` names the
+ cycle and refuses. Repetition is `each` over a list with a limit, so
+ every run is finite and every trace is complete.
+
+## 2. Invariants
+
+Everything after this section is arranged so that none of these can be
+traded away by a convenient feature.
+
+1. **Determinism given observations.** The same tree, inputs and responses
+ produce the same path, outcome and trace. Tested by running every
+ example against a recorded fixture.
+2. **The trace is complete.** Every node visited appears, with what it
+ observed and, for a decision, which condition held. Delegated runs
+ appear inside the caller's trace one level deeper.
+3. **Hosts are closed.** No request leaves to a host outside the tree's
+ list, whether the URL was literal or built at run time.
+4. **Reads retry; writes do not.** An `observe` may be retried on transport
+ failure; an `act` is attempted once. The checker refuses `retries` on an
+ act and refuses a write method on an observe.
+5. **Absence is decided, not pasted.** A template whose value is missing
+ fails the node, except in `done` and `fail`. A tree cannot build a URL
+ from an empty string by accident; it must `decide` on `exists`.
+6. **A caller cannot tell a service from a hub.** Both answer `/intent`,
+ `/run`, `/resume`, `/capabilities`, `/health` and `/gaps` with the same
+ shapes, so a swarm can start as one process and split later.
+
+## 3. Architecture as built
+
+```
+sentence ──► intent.py ──► registry.py ──► runtime.py ──► Outcome + Trace
+ (triggers) (trees, reload) │
+ ├─ template.py {{ paths | filters }}
+ ├─ conditions.py when = { eq, between, … }
+ ├─ extract.py json / regex / css
+ │ └─ html.py parser + selector subset
+ └─ transport.py urllib | recorded | recording
+
+service.py one process, one registry, HTTP endpoints, hot reload, gaps
+hub.py peers, link up/stale/down, routing by capability, forwarding
+cli.py check · list · run · ask · serve · hub · gaps · new
+```
+
+**The tree** (`tree.py`) is TOML, loaded into dataclasses and checked on
+load. `check()` returns problems at three levels; an error refuses the
+load. It verifies references, conditions, regexes, selectors, input
+declarations against template use, hosts against literal URLs,
+reachability, and acyclicity. A `decide` without `else` is an `info`: it is
+the author's statement that an unforeseen value should stop the run and be
+reported, which is often right.
+
+**Intent matching** (`intent.py`) compiles trigger patterns to regexes.
+Specificity is the count of literal characters; the most specific match
+wins, and a tie is an `ambiguous` outcome. A pattern with no literal
+characters is refused because it would match everything.
+
+**The runtime** (`runtime.py`) is one loop over node kinds. State is a
+dictionary; scope adds `inputs`, `item`, `index`, `tree`, `now` and `env`.
+`each` runs another tree per item (through the registry, then the
+resolver) or a local sub-flow (the same tree from another start node, with
+shared state). `delegate` does the same for one sentence or name. `ask`
+returns a snapshot the caller can `resume`; only a top-level run can ask,
+because a paused sub-run would need a call stack the snapshot does not
+carry.
+
+**Transport** (`transport.py`) is a protocol with three implementations:
+the standard library's client, a recorded fixture that raises on any
+request it did not foresee, and a recorder that writes such fixtures.
+Every example tree is tested offline through the second, and
+`pyrtan run --record` produces fixtures through the third.
+
+**The service** (`service.py`) wraps a registry in the standard library's
+threading HTTP server. It reloads trees from disk on every request that
+needs them (a stat per file; cheap), keeps the gaps it has seen, and, when
+given a hub, registers itself and forwards unknown trees there.
+
+**The hub** (`hub.py`) holds no trees. It polls peers' `/capabilities`,
+derives each peer's `link` from the age of its last good answer (`up` under
+30 s, `stale` under 90 s, `down` after), routes a sentence to the peer
+whose tree matches best, and forwards. The `link` field is the one thing an
+operator reads; it is an idea carried over from an earlier product of
+the same name, kept because it was right.
+
+**No dependencies.** The runtime imports only the standard library:
+`tomllib`, `html.parser`, `urllib`, `http.server`, `re`, `json`. This was a
+deliberate choice over a web framework and an HTML library. A tool that
+asks to be installed next to someone's existing automation should not
+bring a dependency tree with it, and the HTML selector subset trees
+actually need is small enough to own (`html.py`, about 250 lines, tested).
+A browser adapter will be an optional extra, not a requirement.
+
+## 4. Paths considered and not taken
+
+**Rust.** The earlier product of this name chose Rust for a 6 MB resident
+footprint on site boxes. Pyrtan's services run wherever an operator already
+has Python, and its contributors are people who write trees, not people
+who write runtimes. Python with no dependencies installs anywhere, and
+`uv` makes a checkout usable in one command. If a service ever needs to
+run on a constrained box, the tree format is language-neutral and a second
+runtime can read the same files.
+
+**YAML or JSON for trees.** TOML has comments, which JSON lacks, and a
+standard-library parser, which YAML lacks. Its one trap, top-level keys
+after a table header, is caught by the loader with a hint.
+
+**An expression language in templates.** Rejected per Section 1. The
+weather tree is longer for it; it is also readable by a non-programmer end
+to end.
+
+**A model fallback for unmatched sentences.** The obvious feature, and the
+one that would quietly turn the product into the thing it is defined
+against. Not built. A sentence no tree answers returns `no-match`, and the
+honest response to a frequent `no-match` is a new tree.
+
+**A message bus between services.** NATS or similar would give the swarm
+events and fan-out. v0.1 has `act.emit`, which records an event on the
+service; nothing consumes it yet. Request/response over HTTP covers every
+case the examples needed, and a bus would be a dependency and an
+operational component before there is a swarm large enough to need one.
+
+**Persisting gaps.** Gaps live in a service's memory and are lost on
+restart. Enough to prove the loop (run, see gap, add branch, reload), not
+enough for a fleet. Deferred to Section 7.
+
+## 5. What the examples show
+
+Five trees, all against real public APIs, all tested offline:
+
+| Tree | Shows |
+| :-- | :-- |
+| `hacker-news` + `hacker-news-item` | `each` over a list through a second tree; a decision on status before one on content; a self-post with no URL handled by `set` rather than by an empty string |
+| `weather` | Two hops where the second depends on the first; `exists` deciding on absence; a code-to-word table as seven decisions, not an expression |
+| `site-status` | A host from the sentence, so `hosts = ["*"]` and a run-time host check; `between` on status ranges; a client error reported as "up but refusing" rather than "down" |
+| `github-release` | Headers; a decision that names rate-limiting as its own branch |
+| `picnic` (in the tests) | One service delegating a sentence through the hub to another, and the other's steps returned inside its trace |
+
+## 6. Sequencing
+
+1. **v0.1, built.** Trees, checker, runtime, trace, service, hub, command,
+ examples, tests. This document.
+2. **v0.2: first real swarm.** Three or more services on separate boxes
+ behind one hub, with a status page off `/peers`. Gaps persisted to a
+ file per service and collected by the hub. A `pyrtan watch` that
+ re-runs a set of sentences on a schedule and reports when a path changes
+ (the way to learn a site has changed before a person does).
+3. **v0.3: browser adapter.** `observe` with `browser = { url, wait_for,
+ actions }` on Playwright as an optional extra, for pages that need a
+ script to render, with the same host rule and the same extraction.
+4. **v0.4: trees as a shared thing.** A directory of trees people can pull
+ by name, with the checker run on contribution, and `version` meaning
+ something.
+5. **Beside the swarm, never inside it:** an authoring aid that reads gaps
+ and drafts the branch for a person to accept.
+
+## 7. Open questions
+
+- **Authentication between services.** None in v0.1; the swarm is assumed
+ to sit on a trusted network behind an operator's ingress. The first
+ swarm that crosses a network boundary needs a shared secret or mTLS at
+ the hub, and `env` already carries per-service secrets for upstream APIs.
+- **What a tree's `version` governs.** A hub could prefer the highest
+ version of a name across peers, or refuse to route to a peer whose
+ version is below the caller's. Undecided until two services hold the
+ same tree at different versions.
+- **Whether `ask` belongs in a swarm at all.** A pause needs a place to
+ keep the snapshot; v0.1 hands it back to the caller. That is right for a
+ command and awkward for a service behind a hub. A `pending` store on the
+ hub is the likely answer.
+- **How far `html.py` should go.** The selector subset covers every example
+ and every test. Sites that need `:not()`, sibling combinators or
+ attribute case-folding will push on it; the alternative is an optional
+ dependency on a full selector engine, which the no-dependency rule
+ allows as an extra but not as a requirement.
+
+## 8. What this design does not claim
+
+It does not claim to answer sentences nobody wrote a tree for. It does not
+claim trees are quick to write: the weather tree is 15 nodes for a two-call
+API, and that is the price of leaving nothing implicit. It does not claim
+the swarm is secure across a network boundary (Section 7). It does not
+claim the examples will keep working as the sites they read change; it
+claims that when they stop, the trace will say exactly where, and a gap
+will say what was seen.
+
+## The name
+
+The name Pyrtan was used earlier for an unrelated building-management
+product. None of its code is in this repository. The one idea carried over
+is that a fleet's health is one pollable field, which is the hub's `link`.
diff --git a/_meta/tree-format.md b/_meta/tree-format.md new file mode 100644 index 0000000..2705f56 --- /dev/null +++ b/_meta/tree-format.md
@@ -0,0 +1,230 @@
+# The tree format
+
+A tree is one TOML file. This is the whole of it.
+
+## Top level
+
+| Key | Required | Meaning |
+| :-- | :-- | :-- |
+| `name` | yes | Lowercase letters, digits and dashes. The tree's identity in the swarm. |
+| `description` | should | One line. Shown by `pyrtan list` and in `/capabilities`. |
+| `version` | no | Positive integer, default 1. |
+| `hosts` | if any node speaks HTTP | The hosts the tree may reach: exact names, `*.example.com`, or `*` for a tree whose host comes from its input. |
+| `start` | yes | The first node. |
+| `tags` | no | Free strings, for people and for the hub's listing. |
+| `triggers` | no | Sentences the tree answers to (below). |
+| `inputs` | no | What the tree needs (below). |
+| `nodes` | yes | The nodes (below). |
+
+TOML puts top-level keys before the first `[table]`; `start` after
+`[inputs.x]` lands inside `inputs.x`, and the loader says so.
+
+## Triggers
+
+```toml
+[[triggers]]
+pattern = "[what's the] weather in {place}"
+
+[[triggers]]
+pattern = "top {count:int} stories on hacker news"
+inputs = { count = 1 } # fixed inputs this sentence implies
+```
+
+A slot is `{name}` (any text), `{name:int}`, `{name:float}` or `{name:word}`
+(no spaces). Every slot must be a declared input. `[words]` are optional;
+`(a|b)` is a required choice; `|` separates alternatives inside either.
+Matching ignores case, surrounding punctuation and repeated spaces; a slot's
+text keeps its case. A pattern needs at least one literal word.
+
+When several trees match a sentence, the trigger with the most literal
+characters wins. An exact tie is answered as `ambiguous`, naming the
+candidates, rather than resolved by load order.
+
+## Inputs
+
+```toml
+[inputs.count]
+type = "int" # str | int | float | bool | list | dict
+default = 1 # an input with a default is optional
+min = 1
+max = 30
+one_of = [1, 5, 10]
+pattern = "^[a-z]+$" # for str
+description = "How many"
+required = false # optional with no default: present as null
+```
+
+Inputs are coerced (`"5"` to 5, `"yes"` to true) and checked before the
+first node runs; a bad input is a `bad-input` error, never a half run.
+
+## Nodes
+
+Every node has `kind`. Every non-terminal node except `decide` has `next`.
+Node ids are lowercase letters, digits and dashes.
+
+### `observe`: read
+
+```toml
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.example.com/x/{{ inputs.id }}", headers = { Accept = "application/json" } }
+into = "resp" # state.resp = { status, ok, headers, body, json, url, elapsed_ms }
+extract = [
+ { into = "title", json = "json.title" },
+ { into = "price", regex = "\\$([0-9.]+)", group = 1 },
+ { into = "heading", css = "h1" },
+ { into = "links", css = "a.story", attr = "href", all = true },
+ { into = "rows", css = "table tr", all = true, fields = { name = "td:nth-child(1)", href = { css = "a", attr = "href" } } },
+]
+timeout = 10 # seconds
+retries = 1 # on transport failure only; never on a status code
+next = "status"
+```
+
+`method` must be GET, HEAD or OPTIONS. Instead of `http`, `from = "resp"`
+extracts from state already held. An extraction that finds nothing sets
+nothing; decide on absence with `{ exists = false }`.
+
+Selectors support tag, `#id`, `.class`, `[attr]`, `[attr=v]`, `[attr^=v]`,
+`[attr$=v]`, `[attr*=v]`, `:first-child`, `:last-child`, `:nth-child(n)`,
+`:contains(text)`, the descendant and `>` combinators, and `,` groups.
+Anything else fails `pyrtan check`.
+
+### `act`: change
+
+Same shape as `observe` with any method, or an event:
+
+```toml
+[nodes.notify]
+kind = "act"
+emit = { event = "order.placed", data = { id = "{{ state.order.id }}" } }
+into = "sent"
+next = "reply"
+```
+
+An act is never retried. A GET with no body in an act is a warning: it is a
+read, and `observe` would retry it safely.
+
+### `decide`: branch
+
+```toml
+[nodes.status]
+kind = "decide"
+on = "resp.status" # a state path; "resp.status" means "state.resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { between = [500, 599] }, then = "down" },
+ { else = "unexpected" },
+]
+```
+
+Conditions, all of which must hold within one `when`: `eq`, `ne`, `lt`,
+`lte`, `gt`, `gte`, `between = [lo, hi]`, `matches = "regex"`, `contains`,
+`startswith`, `endswith`, `in = [...]`, `exists = true|false`,
+`empty = true|false`, `type = "str|int|float|number|bool|list|dict|null"`.
+Numbers compare loosely with numeric strings; booleans compare strictly.
+
+The first branch whose condition holds is taken. `else` must be last. A
+`decide` with no `else` that meets a value no branch covers records a
+**gap** (`tree`, `node`, `on`, `observed`) and ends the run with reason
+`gap`. `pyrtan gaps --at URL` lists them.
+
+### `set`: compute
+
+```toml
+[nodes.self-post]
+kind = "set"
+set = { url = "https://news.ycombinator.com/item?id={{ inputs.id }}", n = "{{ state.ids | length }}" }
+next = "reply"
+```
+
+### `each`: walk a list
+
+```toml
+[nodes.stories]
+kind = "each"
+over = "ids"
+limit = "{{ inputs.count }}"
+tree = "hacker-news-item" # run this tree per item …
+with = { id = "{{ item }}" }
+into = "stories" # … collecting each output (null where one failed)
+stop_on_fail = false
+next = "reply"
+```
+
+Or `start = "node"` instead of `tree`, to run a sub-flow of this tree per
+item with `{{ item }}` and `{{ index }}` in scope, ending at the sub-flow's
+first `done` (its `output` is collected) or `fail`. The sub-flow shares the
+run's state. An `each` without `limit` is a warning.
+
+### `delegate`: ask the swarm
+
+```toml
+[nodes.weather]
+kind = "delegate"
+intent = "weather in {{ inputs.place }}" # or: tree = "weather", with = { place = "…" }
+into = "w" # state.w = { status, say, output, reason, tree }
+next = "check"
+```
+
+A delegate is resolved locally first, then through the service's hub. The
+delegated run's steps appear in the caller's trace one level deeper. A
+delegated tree cannot `ask`.
+
+### `ask`: pause for a person
+
+```toml
+[nodes.which]
+kind = "ask"
+prompt = "Which city?"
+into = "inputs.city" # or a state path
+next = "geocode"
+```
+
+The run returns `asked` with the prompt and a snapshot; `/resume` (or
+`pyrtan run` interactively) continues it. Only a top-level run can ask.
+
+### `done` and `fail`: finish
+
+```toml
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}: {{ state.temp }}°"
+output = { place = "{{ state.name }}", temperature = "{{ state.temp }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place" # a short code, for callers
+say = "I could not find {{ inputs.place }}."
+```
+
+In `say` and `output` a missing value renders as empty rather than failing
+the run, since the run is over.
+
+## Templates
+
+`{{ path }}` with optional filters: `{{ inputs.city | url }}`. Roots:
+`inputs`, `state`, `item`, `index` (inside `each`), `tree` (`name`,
+`version`), `now` (`iso`, `date`, `epoch`, `year`), `env` (values the
+service was started with, for tokens). Paths take `.key`, `[0]`, `[-1]`,
+`["key"]`.
+
+Filters: `url`, `json`, `int`, `float`, `str`, `lower`, `upper`, `strip`,
+`title`, `first`, `last`, `length`.
+
+A string that is exactly one placeholder keeps the value's type (a list
+stays a list). A placeholder that resolves to nothing fails the node, except
+in `done` and `fail`, so a tree must decide on absence rather than paste an
+empty string into a URL.
+
+## Outcomes
+
+| `status` | Meaning | Carries |
+| :-- | :-- | :-- |
+| `done` | a `done` node was reached | `say`, `output` |
+| `failed` | a `fail` node was reached | `reason`, `say` |
+| `asked` | an `ask` node paused the run | `question`, `snapshot` |
+| `error` | the run could not proceed | `reason`: `no-match`, `ambiguous`, `no-such-tree`, `bad-input`, `host-not-allowed`, `transport`, `gap`, `template`, `too-deep`, `too-many-steps`, `not-a-list`, `each-failed`, `ask-in-delegate`, `ask-in-subflow`, `dead-end` |
+
+Every outcome carries `trace`: `steps` (node, kind, summary, observed,
+chosen, next, elapsed_ms, depth) and `gaps`.
diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..53307b1 --- /dev/null +++ b/deploy/README.md
@@ -0,0 +1,115 @@
+# Running the demo on OpenMediaVault
+
+Two ways. Docker through OMV's Compose plugin is the one that fits the box;
+the systemd unit is for running it bare on the host.
+
+Replace `<nas-ip>` below with your NAS's address on the LAN. Both end with
+the demo page at `http://<nas-ip>:8700/` (Docker) or `http://<nas-ip>:8701/`
+(systemd): a sentence box, the answer, the
+output, and the trace, plus the list of everything the swarm can answer.
+
+## Docker, through the Compose plugin
+
+The stack in [`openmediavault/docker-compose.yml`](openmediavault/docker-compose.yml)
+runs four containers: a **hub** on port 8700, two example services
+(**news**: Hacker News and GitHub; **weather**: Open-Meteo and site status)
+that register with the hub over the compose network, and **mine**, which
+serves a folder of your own trees from a shared folder and reloads them
+when you edit a file.
+
+1. **Install the plugin**, if it is not there: in OMV, *System › Plugins*,
+ install `openmediavault-compose` (needs omv-extras on OMV 6/7). It
+ installs Docker.
+2. **Pick a shared folder** for the files. Say the share is `appdata` on
+ the data disk; on the host that is a path like
+ `/srv/dev-disk-by-uuid-…/appdata`. Find it under *Storage › Shared
+ Folders*, or run `ls /srv` over SSH.
+3. **Clone the repository there**, over SSH on the NAS:
+
+ ```
+ cd /srv/dev-disk-by-uuid-…/appdata
+ git clone https://github.com/b-i-t-8/pyrtan.git
+ mkdir -p pyrtan/trees
+ ```
+
+ `pyrtan/trees` is the folder **mine** watches. Copy an example in to
+ start: `cp pyrtan/examples/site-status/site-status.toml pyrtan/trees/`.
+4. **Add the stack**: *Services › Compose › Files › Add*. Name it `pyrtan`,
+ paste the compose file, and replace both `CHANGE-ME` paths with the path
+ from step 2 (the checkout at `…/appdata/pyrtan`, the trees folder at
+ `…/appdata/pyrtan/trees`). Save.
+5. **Up**: select the file, press *Up*. The first time it builds the image
+ on the NAS, which takes a minute or two.
+6. **Open** `http://<nas-ip>:8700/`. The page should say `4/4 peers
+ up` within ten seconds (the hub polls every ten). Try "top 3 stories on
+ hacker news".
+
+To update: `git pull` in the checkout, then *Down* and *Up* with *Build*
+ticked (or `docker compose -p pyrtan up -d --build` over SSH). To check a
+tree you dropped into `trees/` before it is live:
+`docker compose -p pyrtan exec mine pyrtan check /trees`. To see gaps the
+demo has met: `http://<nas-ip>:8700/gaps`.
+
+The services do not expose their own ports; everything goes through the
+hub on 8700. Nothing authenticates: this is a demo for your LAN. Do not
+forward 8700 to the internet.
+
+### Without the plugin
+
+Over SSH, from the checkout:
+
+```
+cd /srv/dev-disk-by-uuid-…/appdata/pyrtan
+cp deploy/openmediavault/docker-compose.yml .
+sed -i "s#/srv/dev-disk-by-uuid-CHANGE-ME/pyrtan#$PWD#g" docker-compose.yml
+docker compose up -d --build
+```
+
+### One container only
+
+If you want a single process and no swarm:
+
+```
+docker build -t pyrtan:local .
+docker run -d --name pyrtan -p 8701:8701 --restart unless-stopped pyrtan:local
+```
+
+It serves the examples at `http://<nas-ip>:8701/`.
+
+## Bare, with systemd
+
+For a host with no Docker. Pyrtan needs Python 3.12+ and OMV 7 ships 3.11,
+so `uv` fetches an interpreter. Everything is installed under `/opt/pyrtan`
+and run by a system user with no home directory.
+
+```
+curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
+sudo env UV_TOOL_DIR=/opt/pyrtan/tools UV_TOOL_BIN_DIR=/opt/pyrtan/bin \
+ UV_PYTHON_INSTALL_DIR=/opt/pyrtan/python \
+ uv tool install --python 3.12 git+https://github.com/b-i-t-8/pyrtan
+sudo useradd --system --no-create-home --shell /usr/sbin/nologin pyrtan
+sudo mkdir -p /var/lib/pyrtan/trees
+sudo cp -r /path/to/pyrtan/examples/* /var/lib/pyrtan/trees/
+sudo chown -R pyrtan:pyrtan /var/lib/pyrtan
+sudo cp deploy/systemd/pyrtan-demo.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now pyrtan-demo
+curl http://127.0.0.1:8701/health
+```
+
+The unit runs one process holding every tree in `/var/lib/pyrtan/trees` on
+port 8701, hardened to read its trees and reach the network and nothing
+else. Logs: `journalctl -u pyrtan-demo -f`. To update, run the
+same `uv tool install` line with `--force`, then
+`sudo systemctl restart pyrtan-demo`.
+
+## What the demo shows
+
+- A sentence routed by the hub to the service whose tree answers it, and
+ the trace of every observation and decision on the way back.
+- A tree in one service delegating to a tree in another: ask
+ "top 3 stories on hacker news" and watch `hacker-news` on **news** call
+ `hacker-news-item` three times.
+- Hot reload: edit a file in `trees/` from the share and ask again.
+- Gaps: a decision that met a value no branch covers, at `/gaps`.
+- Health as one field: `/peers` on the hub shows each service's `link`.
diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml new file mode 100644 index 0000000..2fad1c3 --- /dev/null +++ b/deploy/openmediavault/docker-compose.yml
@@ -0,0 +1,58 @@
+# Pyrtan demo swarm for OpenMediaVault (or any Docker host).
+#
+# One hub, reachable from the LAN, and two services behind it that each
+# hold part of the examples, so the demo shows routing between services
+# rather than one process. A third service holds a trees/ folder you can
+# edit on the NAS: drop a .toml in and it is live on the next request.
+#
+# Edit the two CHANGE-ME paths (the checkout, and a trees folder) to match
+# your shared folder, then in OMV: Services > Compose > Files > Add (paste
+# this) > Up. Steps in ../README.md.
+#
+# Open http://<nas-ip>:8700/ for the demo page.
+
+name: pyrtan
+
+x-pyrtan: &pyrtan
+ # Built on the NAS from a checkout of the repository (no image is
+ # published yet). The context is the repository root.
+ build:
+ context: /srv/dev-disk-by-uuid-CHANGE-ME/pyrtan
+ image: pyrtan:local
+ restart: unless-stopped
+ read_only: true
+ security_opt: [no-new-privileges:true]
+ cap_drop: [ALL]
+ tmpfs: [/tmp]
+ logging:
+ driver: json-file
+ options: { max-size: "5m", max-file: "3" }
+
+services:
+ hub:
+ <<: *pyrtan
+ command: ["hub", "--host", "0.0.0.0", "--port", "8700", "--poll", "10"]
+ environment:
+ PYRTAN_PORT: "8700"
+ ports:
+ - "8700:8700"
+
+ news:
+ <<: *pyrtan
+ command: ["serve", "/examples/hacker-news", "/examples/github", "--host", "0.0.0.0", "--port", "8701", "--name", "news", "--hub", "http://hub:8700", "--advertise", "http://news:8701"]
+ depends_on: [hub]
+
+ weather:
+ <<: *pyrtan
+ command: ["serve", "/examples/weather", "/examples/site-status", "--host", "0.0.0.0", "--port", "8701", "--name", "weather", "--hub", "http://hub:8700", "--advertise", "http://weather:8701"]
+ depends_on: [hub]
+
+ # Your own trees. Edit files in this folder from a share; the service
+ # reloads them without a restart. `pyrtan check` them first:
+ # docker compose -p pyrtan exec mine pyrtan check /trees
+ mine:
+ <<: *pyrtan
+ command: ["serve", "/trees", "--host", "0.0.0.0", "--port", "8701", "--name", "mine", "--hub", "http://hub:8700", "--advertise", "http://mine:8701"]
+ volumes:
+ - /srv/dev-disk-by-uuid-CHANGE-ME/pyrtan/trees:/trees:ro
+ depends_on: [hub]
diff --git a/deploy/systemd/pyrtan-demo.service b/deploy/systemd/pyrtan-demo.service new file mode 100644 index 0000000..3246d60 --- /dev/null +++ b/deploy/systemd/pyrtan-demo.service
@@ -0,0 +1,38 @@
+# Pyrtan demo as one systemd service, without Docker: a single process
+# holding every example tree, serving the demo page on port 8701.
+#
+# sudo cp pyrtan-demo.service /etc/systemd/system/
+# sudo systemctl daemon-reload && sudo systemctl enable --now pyrtan-demo
+#
+# Assumes pyrtan was installed under /opt/pyrtan as deploy/README.md
+# describes, so the command lives at /opt/pyrtan/bin/pyrtan. Trees are read
+# from /var/lib/pyrtan/trees; edit TREES to taste.
+
+[Unit]
+Description=Pyrtan demo swarm (single process)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=pyrtan
+Group=pyrtan
+Environment=PYTHONUNBUFFERED=1
+Environment=TREES=/var/lib/pyrtan/trees
+ExecStart=/opt/pyrtan/bin/pyrtan serve ${TREES} --host 0.0.0.0 --port 8701 --name demo
+Restart=on-failure
+RestartSec=3
+# Hardening: the process needs the network and read access to its trees, nothing else.
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+ReadOnlyPaths=/opt/pyrtan /var/lib/pyrtan
+PrivateTmp=true
+ProtectKernelTunables=true
+ProtectControlGroups=true
+RestrictSUIDSGID=true
+LockPersonality=true
+MemoryMax=256M
+
+[Install]
+WantedBy=multi-user.target
diff --git a/examples/github/github-release.toml b/examples/github/github-release.toml new file mode 100644 index 0000000..1ce20ef --- /dev/null +++ b/examples/github/github-release.toml
@@ -0,0 +1,62 @@
+# Latest release of a GitHub repository, from the public API. Shows an
+# optional token from the service's env and a decision on the status code.
+
+name = "github-release"
+version = 1
+description = "The latest release of a GitHub repository."
+tags = ["github", "read-only"]
+hosts = ["api.github.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest release of {repo:word}"
+[[triggers]]
+pattern = "latest {repo:word} release"
+
+[inputs.repo]
+type = "str"
+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"
+description = "owner/name"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}/releases/latest", headers = { Accept = "application/vnd.github+json" } }
+into = "resp"
+extract = [
+ { into = "tag", json = "json.tag_name" },
+ { into = "name", json = "json.name" },
+ { into = "published", json = "json.published_at" },
+ { into = "url", json = "json.html_url" },
+ { into = "prerelease", json = "json.prerelease" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "none" },
+ { when = { in = [403, 429] }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"
+output = { repo = "{{ inputs.repo }}", tag = "{{ state.tag }}", name = "{{ state.name }}", published = "{{ state.published }}", url = "{{ state.url }}", prerelease = "{{ state.prerelease }}" }
+
+[nodes.none]
+kind = "fail"
+reason = "no-release"
+say = "{{ inputs.repo }} has no releases, or does not exist."
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "GitHub answered {{ state.resp.status }}."
diff --git a/examples/hacker-news/hacker-news-item.toml b/examples/hacker-news/hacker-news-item.toml new file mode 100644 index 0000000..19e50d5 --- /dev/null +++ b/examples/hacker-news/hacker-news-item.toml
@@ -0,0 +1,65 @@
+name = "hacker-news-item"
+version = 1
+description = "One Hacker News item by id: title, url, score, author."
+tags = ["news", "read-only"]
+hosts = ["hacker-news.firebaseio.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "hacker news item {id:int}"
+
+[inputs.id]
+type = "int"
+min = 1
+description = "The item id"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json" }
+into = "resp"
+extract = [
+ { into = "title", json = "json.title" },
+ { into = "url", json = "json.url" },
+ { into = "score", json = "json.score" },
+ { into = "by", json = "json.by" },
+ { into = "type", json = "json.type" },
+]
+next = "kind"
+
+[nodes.kind]
+kind = "decide"
+on = "type"
+branches = [
+ { when = { eq = "story" }, then = "has-url" },
+ { when = { eq = "job" }, then = "has-url" },
+ { when = { exists = false }, then = "missing" },
+ { else = "not-a-story" },
+]
+
+[nodes.has-url]
+kind = "decide"
+on = "url"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "self-post" },
+]
+
+[nodes.self-post]
+kind = "set"
+set = { url = "https://news.ycombinator.com/item?id={{ inputs.id }}" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"
+output = { id = "{{ inputs.id }}", title = "{{ state.title }}", url = "{{ state.url }}", score = "{{ state.score }}", by = "{{ state.by }}" }
+
+[nodes.missing]
+kind = "fail"
+reason = "no-such-item"
+say = "There is no Hacker News item {{ inputs.id }}."
+
+[nodes.not-a-story]
+kind = "fail"
+reason = "not-a-story"
+say = "Item {{ inputs.id }} is a {{ state.type }}, not a story."
diff --git a/examples/hacker-news/hacker-news.toml b/examples/hacker-news/hacker-news.toml new file mode 100644 index 0000000..37ba031 --- /dev/null +++ b/examples/hacker-news/hacker-news.toml
@@ -0,0 +1,78 @@
+# Hacker News, through its public Firebase API. Two trees: this one answers
+# the question, and `hacker-news-item` fetches one story, so that `each`
+# can reuse it.
+
+name = "hacker-news"
+version = 1
+description = "Top stories on Hacker News, from its public API."
+tags = ["news", "read-only"]
+hosts = ["hacker-news.firebaseio.com"]
+start = "fetch-ids"
+
+[[triggers]]
+pattern = "[what's the] top story on hacker news"
+inputs = { count = 1 }
+
+[[triggers]]
+pattern = "top {count:int} stories on hacker news"
+
+[inputs.count]
+type = "int"
+default = 1
+min = 1
+max = 30
+description = "How many stories to return"
+
+[nodes.fetch-ids]
+kind = "observe"
+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/topstories.json" }
+into = "ids_resp"
+extract = [{ into = "ids", json = "json" }]
+next = "check"
+
+[nodes.check]
+kind = "decide"
+on = "ids_resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { when = { gte = 500 }, then = "hn-down" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "ids"
+branches = [
+ { when = { empty = true }, then = "nothing" },
+ { when = { type = "list" }, then = "stories" },
+ { else = "unexpected" },
+]
+
+[nodes.stories]
+kind = "each"
+over = "ids"
+limit = "{{ inputs.count }}"
+tree = "hacker-news-item"
+with = { id = "{{ item }}" }
+into = "stories"
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"
+output = { stories = "{{ state.stories }}" }
+
+[nodes.nothing]
+kind = "fail"
+reason = "no-stories"
+say = "Hacker News returned an empty front page."
+
+[nodes.hn-down]
+kind = "fail"
+reason = "upstream-down"
+say = "Hacker News answered {{ state.ids_resp.status }}; try again later."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."
diff --git a/examples/site-status/site-status.toml b/examples/site-status/site-status.toml new file mode 100644 index 0000000..49a59c3 --- /dev/null +++ b/examples/site-status/site-status.toml
@@ -0,0 +1,61 @@
+# Is a site up? The host comes from the sentence, so it cannot be checked
+# at load time; the runtime refuses anything outside `hosts`, and this tree
+# allows any host on purpose, with the wildcard spelled out.
+
+name = "site-status"
+version = 1
+description = "Whether a web site answers, and how quickly."
+tags = ["ops", "read-only"]
+hosts = ["*"]
+start = "probe"
+
+[[triggers]]
+pattern = "is {host:word} up"
+[[triggers]]
+pattern = "is {host:word} down"
+[[triggers]]
+pattern = "[check] status of {host:word}"
+
+[inputs.host]
+type = "str"
+pattern = "(?i)^[a-z0-9.\\-]+(:[0-9]+)?$"
+description = "A host name, with no scheme"
+
+[nodes.probe]
+kind = "observe"
+http = { method = "GET", url = "https://{{ inputs.host | lower }}/" }
+into = "resp"
+timeout = 8
+retries = 1
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { between = [200, 399] }, then = "up" },
+ { when = { between = [400, 499] }, then = "refusing" },
+ { when = { gte = 500 }, then = "erroring" },
+ { else = "odd" },
+]
+
+[nodes.up]
+kind = "done"
+say = "{{ inputs.host }} is up: {{ state.resp.status }} in {{ state.resp.elapsed_ms }} ms."
+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", elapsed_ms = "{{ state.resp.elapsed_ms }}" }
+
+[nodes.refusing]
+kind = "done"
+say = "{{ inputs.host }} answers but refuses the front page ({{ state.resp.status }}); the server is up."
+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", note = "client error on /" }
+
+[nodes.erroring]
+kind = "fail"
+reason = "server-error"
+say = "{{ inputs.host }} is answering {{ state.resp.status }}: up, but broken."
+output = { host = "{{ inputs.host }}", up = false, status = "{{ state.resp.status }}" }
+
+[nodes.odd]
+kind = "fail"
+reason = "unexpected-status"
+say = "{{ inputs.host }} answered {{ state.resp.status }}, which this tree has no branch for."
diff --git a/examples/weather/weather.toml b/examples/weather/weather.toml new file mode 100644 index 0000000..032d624 --- /dev/null +++ b/examples/weather/weather.toml
@@ -0,0 +1,128 @@
+# Weather for a place, from Open-Meteo (no key needed). Two hops: geocode
+# the name, then fetch the forecast for the coordinates. Each hop has its
+# own decision so a failure says which hop failed.
+
+name = "weather"
+version = 1
+description = "Current weather for a place, from Open-Meteo."
+tags = ["weather", "read-only"]
+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[what's the] weather in {place}"
+[[triggers]]
+pattern = "[what's the] weather for {place}"
+[[triggers]]
+pattern = "how [hot|cold|warm] is it in {place}"
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+[inputs.units]
+type = "str"
+default = "celsius"
+one_of = ["celsius", "fahrenheit"]
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "forecast" },
+ { else = "unknown-place" },
+]
+
+[nodes.forecast]
+kind = "observe"
+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&temperature_unit={{ inputs.units }}" }
+into = "fc"
+extract = [
+ { into = "temp", json = "json.current.temperature_2m" },
+ { into = "humidity", json = "json.current.relative_humidity_2m" },
+ { into = "wind", json = "json.current.wind_speed_10m" },
+ { into = "code", json = "json.current.weather_code" },
+ { into = "unit", json = "json.current_units.temperature_2m" },
+]
+next = "have-temp"
+
+[nodes.have-temp]
+kind = "decide"
+on = "temp"
+branches = [
+ { when = { type = "number" }, then = "sky" },
+ { else = "no-forecast" },
+]
+
+# WMO weather codes, folded into words a person would use.
+[nodes.sky]
+kind = "decide"
+on = "code"
+branches = [
+ { when = { eq = 0 }, then = "clear" },
+ { when = { between = [1, 3] }, then = "cloudy" },
+ { when = { between = [45, 48] }, then = "fog" },
+ { when = { between = [51, 67] }, then = "rain" },
+ { when = { between = [71, 77] }, then = "snow" },
+ { when = { between = [80, 82] }, then = "rain" },
+ { when = { between = [85, 86] }, then = "snow" },
+ { when = { between = [95, 99] }, then = "storm" },
+ { else = "unknown-sky" },
+]
+
+[nodes.clear]
+kind = "set"
+set = { sky = "clear" }
+next = "reply"
+[nodes.cloudy]
+kind = "set"
+set = { sky = "cloudy" }
+next = "reply"
+[nodes.fog]
+kind = "set"
+set = { sky = "foggy" }
+next = "reply"
+[nodes.rain]
+kind = "set"
+set = { sky = "raining" }
+next = "reply"
+[nodes.snow]
+kind = "set"
+set = { sky = "snowing" }
+next = "reply"
+[nodes.storm]
+kind = "set"
+set = { sky = "stormy" }
+next = "reply"
+[nodes.unknown-sky]
+kind = "set"
+set = { sky = "weather code {{ state.code }}" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}: {{ state.temp }}{{ state.unit }}, {{ state.sky }}, humidity {{ state.humidity }}%, wind {{ state.wind }} km/h."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", temperature = "{{ state.temp }}", unit = "{{ state.unit }}", sky = "{{ state.sky }}", humidity = "{{ state.humidity }}", wind_kmh = "{{ state.wind }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[nodes.no-forecast]
+kind = "fail"
+reason = "no-forecast"
+say = "Open-Meteo found {{ state.name }} but returned no current temperature (status {{ state.fc.status }})."
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9ceebc --- /dev/null +++ b/pyproject.toml
@@ -0,0 +1,42 @@
+[project]
+name = "pyrtan"
+version = "0.1.0"
+description = "Open-source total automation: a swarm of small services that act on the web through hardcoded, inspectable decision trees."
+readme = "README.md"
+license = "AGPL-3.0-or-later"
+license-files = ["LICENSE"]
+requires-python = ">=3.12"
+dependencies = []
+keywords = ["automation", "decision-tree", "swarm", "agents", "web"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Internet :: WWW/HTTP",
+]
+
+[project.urls]
+Repository = "https://github.com/b-i-t-8/pyrtan"
+
+[project.scripts]
+pyrtan = "pyrtan.cli:main"
+
+[project.optional-dependencies]
+browser = ["playwright>=1.45"]
+
+[dependency-groups]
+dev = ["pytest>=8"]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/pyrtan"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-q"
diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py new file mode 100644 index 0000000..68cfaf5 --- /dev/null +++ b/src/pyrtan/__init__.py
@@ -0,0 +1,34 @@
+"""Pyrtan: a swarm of small services that act on the web through hardcoded,
+inspectable decision trees.
+
+The public surface is small on purpose:
+
+- :func:`pyrtan.load_tree` reads one tree file.
+- :class:`pyrtan.Registry` holds the trees a service knows about.
+- :class:`pyrtan.Runner` executes a tree against a transport and returns an
+ :class:`pyrtan.Outcome` with a full :class:`pyrtan.Trace`.
+"""
+
+from .tree import Tree, load_tree, load_tree_text, TreeError
+from .registry import Registry
+from .runtime import Runner, Outcome
+from .trace import Trace, Step
+from .transport import Transport, UrllibTransport, RecordedTransport, Response
+
+__all__ = [
+ "Tree",
+ "TreeError",
+ "load_tree",
+ "load_tree_text",
+ "Registry",
+ "Runner",
+ "Outcome",
+ "Trace",
+ "Step",
+ "Transport",
+ "UrllibTransport",
+ "RecordedTransport",
+ "Response",
+]
+
+__version__ = "0.1.0"
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py new file mode 100644 index 0000000..cd5bb0c --- /dev/null +++ b/src/pyrtan/cli.py
@@ -0,0 +1,337 @@
+"""The ``pyrtan`` command.
+
+ pyrtan check [PATH ...] validate trees
+ pyrtan list [PATH ...] what is loaded and what it answers to
+ pyrtan run TEXT [--trees PATH] answer a sentence, in this process
+ pyrtan run --tree NAME --input k=v run a named tree
+ pyrtan ask TEXT --at URL answer a sentence through a service or hub
+ pyrtan serve [PATH ...] [--port N] host trees as a service
+ pyrtan hub [--peer URL ...] route between services
+ pyrtan gaps [PATH ... | --at URL] branches waiting to be written
+ pyrtan new NAME [--dir PATH] scaffold a tree file
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+from . import __version__
+from .registry import Registry, default_paths
+from .runtime import Outcome, Runner
+from .service import RemoteResolver, http_json, outcome_from_dict, serve
+from .transport import RecordedTransport, RecordingTransport, UrllibTransport
+from .tree import check, load_tree
+
+TEMPLATE = '''# {name}: say what this tree does, in one line, below.
+
+name = "{name}"
+version = 1
+description = ""
+tags = []
+hosts = ["example.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "{words}"
+
+[nodes.fetch]
+kind = "observe"
+http = {{ method = "GET", url = "https://example.com/" }}
+into = "resp"
+extract = [{{ into = "title", css = "title" }}]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ {{ when = {{ eq = 200 }}, then = "reply" }},
+ {{ else = "unexpected" }},
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{{{ state.title }}}}"
+output = {{ title = "{{{{ state.title }}}}" }}
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "example.com answered {{{{ state.resp.status }}}}."
+'''
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(prog="pyrtan", description="A swarm of small services that act on the web through hardcoded, inspectable decision trees.")
+ parser.add_argument("--version", action="version", version=f"pyrtan {__version__}")
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ p = sub.add_parser("check", help="validate trees; exit 1 if any has an error")
+ p.add_argument("paths", nargs="*")
+ p.add_argument("--quiet", action="store_true", help="only print problems")
+
+ p = sub.add_parser("list", help="what is loaded and what it answers to")
+ p.add_argument("paths", nargs="*")
+ p.add_argument("--json", action="store_true")
+
+ p = sub.add_parser("run", help="answer a sentence or run a named tree in this process")
+ p.add_argument("text", nargs="?", help="the sentence to answer")
+ p.add_argument("--trees", action="append", default=[], help="tree file or directory (repeatable; default $PYRTAN_TREES or ./trees)")
+ p.add_argument("--tree", help="run this tree by name instead of matching text")
+ p.add_argument("--input", action="append", default=[], metavar="K=V", help="an input for --tree (repeatable; JSON values accepted)")
+ p.add_argument("--json", action="store_true", help="print the outcome as JSON")
+ p.add_argument("--no-trace", action="store_true", help="do not print the trace")
+ p.add_argument("--replay", help="answer HTTP from this fixture instead of the network")
+ p.add_argument("--record", help="save every HTTP response to this fixture")
+ p.add_argument("--hub", help="forward trees not held here to this hub or service")
+ p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value trees may read as {{ env.K }} (repeatable)")
+
+ p = sub.add_parser("ask", help="answer a sentence through a running service or hub")
+ p.add_argument("text")
+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="service or hub URL (default $PYRTAN_AT or the local hub)")
+ p.add_argument("--json", action="store_true")
+ p.add_argument("--no-trace", action="store_true")
+
+ p = sub.add_parser("serve", help="host trees as a service")
+ p.add_argument("paths", nargs="*")
+ p.add_argument("--host", default="127.0.0.1")
+ p.add_argument("--port", type=int, default=8701)
+ p.add_argument("--name", default=None, help="what this service calls itself (default: the first directory's name)")
+ p.add_argument("--hub", help="register with this hub, and forward unknown trees to it")
+ p.add_argument("--advertise", help="the URL the hub should reach this service at, if not host:port")
+ p.add_argument("--env", action="append", default=[], metavar="K=V")
+ p.add_argument("--quiet", action="store_true")
+
+ p = sub.add_parser("hub", help="route between services")
+ p.add_argument("--host", default="127.0.0.1")
+ p.add_argument("--port", type=int, default=8700)
+ p.add_argument("--peer", action="append", default=[], help="a service URL to start with (repeatable; services may also register themselves)")
+ p.add_argument("--poll", type=float, default=10.0, help="seconds between health polls")
+ p.add_argument("--quiet", action="store_true")
+
+ p = sub.add_parser("gaps", help="decisions that met a value with no branch")
+ p.add_argument("--at", help="a running service or hub")
+ p.add_argument("--json", action="store_true")
+
+ p = sub.add_parser("new", help="scaffold a tree file")
+ p.add_argument("name")
+ p.add_argument("--dir", default=".")
+
+ args = parser.parse_args(argv)
+ return {"check": cmd_check, "list": cmd_list, "run": cmd_run, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "new": cmd_new}[args.command](args)
+
+
+# ---------------------------------------------------------------- commands
+
+
+def _paths(given: list[str]) -> list[Path]:
+ return [Path(p) for p in given] if given else default_paths()
+
+
+def _files(paths: list[Path]) -> list[Path]:
+ out: list[Path] = []
+ for p in paths:
+ if p.is_dir():
+ out.extend(f for f in sorted(p.rglob("*.toml")) if f.name != "pyproject.toml")
+ elif p.exists():
+ out.append(p)
+ else:
+ print(f"pyrtan: {p}: no such file or directory", file=sys.stderr)
+ return out
+
+
+def cmd_check(args: argparse.Namespace) -> int:
+ worst = 0
+ files = _files(_paths(args.paths))
+ if not files:
+ print("pyrtan: nothing to check", file=sys.stderr)
+ return 2
+ for path in files:
+ try:
+ tree = load_tree(path)
+ except Exception as e: # TreeError or TOML trouble: already explained
+ print(f"{path}: error: {e}")
+ worst = max(worst, 1)
+ continue
+ problems = check(tree)
+ errors = [p for p in problems if p.level == "error"]
+ if errors:
+ worst = max(worst, 1)
+ if not args.quiet or problems:
+ summary = f"{len(tree.nodes)} nodes, {len(tree.triggers)} triggers"
+ print(f"{path}: {tree.name}: {summary}" + ("" if not problems else f", {len(problems)} note(s)"))
+ for p in problems:
+ print(f" {p}")
+ return worst
+
+
+def cmd_list(args: argparse.Namespace) -> int:
+ reg = Registry.from_paths(_paths(args.paths))
+ if args.json:
+ print(json.dumps({"trees": reg.capabilities(), "errors": reg.errors}, indent=2, ensure_ascii=False))
+ return 0
+ for tree in reg.trees():
+ print(f"{tree.name} v{tree.version} {tree.description}")
+ for t in tree.triggers:
+ print(f" {t.pattern}")
+ for path, err in reg.errors.items():
+ print(f"{path}: {err}", file=sys.stderr)
+ return 0 if not reg.errors else 1
+
+
+def _kv(items: list[str]) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ for item in items:
+ if "=" not in item:
+ raise SystemExit(f"pyrtan: expected K=V, got {item!r}")
+ k, v = item.split("=", 1)
+ try:
+ out[k] = json.loads(v)
+ except json.JSONDecodeError:
+ out[k] = v
+ return out
+
+
+def cmd_run(args: argparse.Namespace) -> int:
+ if not args.text and not args.tree:
+ print("pyrtan run: give a sentence, or --tree NAME", file=sys.stderr)
+ return 2
+ reg = Registry.from_paths(_paths(args.trees))
+ for path, err in reg.errors.items():
+ print(f"pyrtan: {path}: {err}", file=sys.stderr)
+ transport: Any = RecordedTransport.from_file(args.replay) if args.replay else UrllibTransport()
+ recorder = None
+ if args.record:
+ recorder = RecordingTransport(transport)
+ transport = recorder
+ runner = Runner(reg, transport, resolver=RemoteResolver(args.hub) if args.hub else None, env=_kv(args.env))
+ if args.tree:
+ outcome = runner.run_tree(args.tree, _kv(args.input))
+ else:
+ outcome = runner.run_intent(args.text)
+ outcome = _answer_questions(outcome, runner.resume)
+ if recorder is not None:
+ recorder.save(args.record)
+ return _report(outcome, args.json, not args.no_trace)
+
+
+def _answer_questions(outcome: Outcome, resume) -> Outcome:
+ while outcome.status == "asked":
+ if not sys.stdin.isatty():
+ return outcome
+ try:
+ answer = input(f"{outcome.question} ")
+ except EOFError:
+ return outcome
+ outcome = resume(outcome.snapshot, answer)
+ return outcome
+
+
+def cmd_ask(args: argparse.Namespace) -> int:
+ at = args.at.rstrip("/")
+ try:
+ status, body = http_json("POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace})
+ except Exception as e:
+ print(f"pyrtan: {at}: {e}", file=sys.stderr)
+ return 3
+ if status != 200 or not isinstance(body, dict):
+ print(f"pyrtan: {at} answered {status}: {body}", file=sys.stderr)
+ return 3
+ outcome = outcome_from_dict(body)
+
+ def remote_resume(snapshot: dict[str, Any], answer: Any) -> Outcome:
+ st, bd = http_json("POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace})
+ return outcome_from_dict(bd) if isinstance(bd, dict) else Outcome("error", "", reason="remote", say=f"{at} answered {st}")
+
+ outcome = _answer_questions(outcome, remote_resume)
+ return _report(outcome, args.json, not args.no_trace)
+
+
+def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int:
+ if as_json:
+ print(json.dumps(outcome.to_dict(with_trace=with_trace), indent=2, ensure_ascii=False, default=str))
+ else:
+ if with_trace and outcome.trace is not None:
+ print(outcome.trace.render())
+ print()
+ if outcome.status == "done":
+ print(outcome.say if outcome.say is not None else json.dumps(outcome.output, ensure_ascii=False))
+ elif outcome.status == "asked":
+ print(f"asked: {outcome.question}")
+ else:
+ print(f"{outcome.status} ({outcome.reason}): {outcome.say or ''}".rstrip(": "))
+ return {"done": 0, "failed": 1, "asked": 4}.get(outcome.status, 3)
+
+
+def cmd_serve(args: argparse.Namespace) -> int:
+ paths = _paths(args.paths)
+ name = args.name or (paths[0].resolve().name if paths else "pyrtan")
+ service, server = serve(paths, args.host, args.port, name=name, hub=args.hub, env=_kv(args.env), advertise=args.advertise, quiet=args.quiet)
+ print(f"pyrtan service {name!r}: {len(service.registry)} tree(s) at {server.url}" + (f", registered with {args.hub}" if args.hub else ""), flush=True)
+ for path, err in service.registry.errors.items():
+ print(f" not loaded: {path}: {err}", file=sys.stderr)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.stop()
+ return 0
+
+
+def cmd_hub(args: argparse.Namespace) -> int:
+ from .hub import serve_hub
+
+ hub, server = serve_hub(args.host, args.port, peers=args.peer, poll_every=args.poll, quiet=args.quiet)
+ print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s)", flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ hub.stop()
+ server.stop()
+ return 0
+
+
+def cmd_gaps(args: argparse.Namespace) -> int:
+ if not args.at:
+ print("pyrtan gaps: give --at URL of a running service or hub", file=sys.stderr)
+ return 2
+ try:
+ status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps")
+ except Exception as e:
+ print(f"pyrtan: {args.at}: {e}", file=sys.stderr)
+ return 3
+ gaps = (body or {}).get("gaps", []) if isinstance(body, dict) else []
+ if args.json:
+ print(json.dumps({"gaps": gaps}, indent=2, ensure_ascii=False))
+ return 0
+ if not gaps:
+ print("no gaps: every decision so far had a branch")
+ return 0
+ for g in gaps:
+ peer = f" ({g['peer']})" if g.get("peer") else ""
+ print(f"{g['tree']}.{g['node']}: {g['on']} = {json.dumps(g['observed'], ensure_ascii=False)}{peer}")
+ return 0
+
+
+def cmd_new(args: argparse.Namespace) -> int:
+ name = args.name
+ path = Path(args.dir) / f"{name}.toml"
+ if path.exists():
+ print(f"pyrtan: {path} exists", file=sys.stderr)
+ return 1
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(TEMPLATE.format(name=name, words=name.replace("-", " ")), encoding="utf-8")
+ print(f"wrote {path}; edit it, then: pyrtan check {path}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/pyrtan/conditions.py b/src/pyrtan/conditions.py new file mode 100644 index 0000000..135e426 --- /dev/null +++ b/src/pyrtan/conditions.py
@@ -0,0 +1,144 @@
+"""Conditions for ``decide`` branches.
+
+A condition is a small table with one or more tests; all tests must hold.
+Every test is a plain comparison a reader can check by eye, which is the
+point: the branch taken is always explainable from the observed value and
+the condition text.
+
+ { eq = 200 }
+ { gte = 500 }
+ { matches = "^https://" }
+ { contains = "error" }
+ { in = ["open", "pending"] }
+ { exists = true }
+ { empty = false }
+ { between = [200, 299] }
+ { type = "list" }
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from .template import MISSING
+
+KNOWN = {"eq", "ne", "lt", "lte", "gt", "gte", "matches", "contains", "in", "exists", "empty", "between", "type", "startswith", "endswith"}
+
+TYPE_NAMES = {
+ "str": str,
+ "string": str,
+ "int": int,
+ "float": float,
+ "number": (int, float),
+ "bool": bool,
+ "list": list,
+ "dict": dict,
+ "map": dict,
+ "null": type(None),
+ "none": type(None),
+}
+
+
+class ConditionError(ValueError):
+ pass
+
+
+def validate(cond: Any) -> None:
+ if not isinstance(cond, dict) or not cond:
+ raise ConditionError("a condition is a non-empty table")
+ unknown = set(cond) - KNOWN
+ if unknown:
+ raise ConditionError(f"unknown condition keys: {sorted(unknown)}")
+ if "matches" in cond:
+ try:
+ re.compile(cond["matches"])
+ except re.error as e:
+ raise ConditionError(f"bad regex in matches: {e}") from e
+ if "between" in cond and (not isinstance(cond["between"], list) or len(cond["between"]) != 2):
+ raise ConditionError("between takes a two-item list")
+ if "type" in cond and cond["type"] not in TYPE_NAMES:
+ raise ConditionError(f"unknown type {cond['type']!r}; one of {sorted(TYPE_NAMES)}")
+ if "in" in cond and not isinstance(cond["in"], list):
+ raise ConditionError("in takes a list")
+
+
+def holds(cond: dict[str, Any], value: Any) -> bool:
+ """True when every test in ``cond`` holds for ``value``."""
+ for key, expected in cond.items():
+ if not _test(key, expected, value):
+ return False
+ return True
+
+
+def _is_empty(value: Any) -> bool:
+ if value is MISSING or value is None:
+ return True
+ if isinstance(value, (str, list, dict, tuple)):
+ return len(value) == 0
+ return False
+
+
+def _num(value: Any) -> float | None:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value.strip())
+ except ValueError:
+ return None
+ return None
+
+
+def _test(key: str, expected: Any, value: Any) -> bool:
+ if key == "exists":
+ return (value is not MISSING and value is not None) == bool(expected)
+ if key == "empty":
+ return _is_empty(value) == bool(expected)
+ if value is MISSING:
+ return False
+ if key == "eq":
+ return _loose_eq(value, expected)
+ if key == "ne":
+ return not _loose_eq(value, expected)
+ if key in ("lt", "lte", "gt", "gte"):
+ a, b = _num(value), _num(expected)
+ if a is None or b is None:
+ return False
+ return {"lt": a < b, "lte": a <= b, "gt": a > b, "gte": a >= b}[key]
+ if key == "between":
+ a, lo, hi = _num(value), _num(expected[0]), _num(expected[1])
+ if a is None or lo is None or hi is None:
+ return False
+ return lo <= a <= hi
+ if key == "matches":
+ return value is not None and re.search(expected, str(value)) is not None
+ if key == "contains":
+ if isinstance(value, (list, tuple)):
+ return any(_loose_eq(item, expected) for item in value)
+ if isinstance(value, dict):
+ return expected in value
+ return str(expected) in str(value)
+ if key == "startswith":
+ return str(value).startswith(str(expected))
+ if key == "endswith":
+ return str(value).endswith(str(expected))
+ if key == "in":
+ return any(_loose_eq(value, option) for option in expected)
+ if key == "type":
+ return isinstance(value, TYPE_NAMES[expected]) and not (expected in ("int", "number", "float") and isinstance(value, bool))
+ raise ConditionError(f"unknown condition key {key!r}")
+
+
+def _loose_eq(a: Any, b: Any) -> bool:
+ """Equality that lets ``"200"`` match ``200`` but keeps bools strict."""
+ if isinstance(a, bool) or isinstance(b, bool):
+ return a is b or (isinstance(a, bool) and isinstance(b, bool) and a == b)
+ if a == b:
+ return True
+ na, nb = _num(a), _num(b)
+ if na is not None and nb is not None and (isinstance(a, str) or isinstance(b, str)):
+ return na == nb
+ return False
diff --git a/src/pyrtan/extract.py b/src/pyrtan/extract.py new file mode 100644 index 0000000..c57b194 --- /dev/null +++ b/src/pyrtan/extract.py
@@ -0,0 +1,120 @@
+"""Pulling values out of what was observed.
+
+An ``extract`` entry names where the value goes (``into``) and one way of
+finding it:
+
+ { into = "title", json = "json.items[0].title" }
+ { into = "price", regex = "\\$([0-9.]+)", group = 1 }
+ { into = "heading", css = "h1" } # text of first match
+ { into = "links", css = "a.story", attr = "href", all = true }
+ { into = "rows", css = "table tr", all = true, fields = { name = "td:nth-child(1)", qty = "td:nth-child(2)" } }
+
+The source is the observation's state (``json``, ``body``, ``status`` …) or
+``from`` when given. Extraction never raises on a miss; the value is simply
+absent, and a ``decide`` node downstream says what that means.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from . import html as htmlmod
+from .template import MISSING, resolve
+
+
+class ExtractError(ValueError):
+ pass
+
+
+WAYS = {"json", "regex", "css", "path"}
+
+
+def validate(entry: Any) -> None:
+ if not isinstance(entry, dict):
+ raise ExtractError("an extract entry is a table")
+ if "into" not in entry or not isinstance(entry["into"], str):
+ raise ExtractError("extract entry needs a string 'into'")
+ ways = WAYS & set(entry)
+ if len(ways) != 1:
+ raise ExtractError(f"extract entry {entry['into']!r} needs exactly one of {sorted(WAYS)}")
+ if "regex" in entry:
+ try:
+ re.compile(entry["regex"])
+ except re.error as e:
+ raise ExtractError(f"bad regex in extract {entry['into']!r}: {e}") from e
+ if "css" in entry:
+ try:
+ htmlmod.compile_selector(entry["css"])
+ except htmlmod.SelectorError as e:
+ raise ExtractError(f"bad selector in extract {entry['into']!r}: {e}") from e
+ for sub in (entry.get("fields") or {}).values():
+ try:
+ htmlmod.compile_selector(sub if isinstance(sub, str) else sub["css"])
+ except (htmlmod.SelectorError, KeyError, TypeError) as e:
+ raise ExtractError(f"bad field selector in extract {entry['into']!r}: {e}") from e
+
+
+def extract(entry: dict[str, Any], source: dict[str, Any], doc_cache: dict[int, htmlmod.Element] | None = None) -> Any:
+ """Return the extracted value, or :data:`MISSING`."""
+ if "json" in entry or "path" in entry:
+ path = entry.get("json", entry.get("path"))
+ return resolve(path, source)
+ if "regex" in entry:
+ text = _text_source(entry, source)
+ if text is MISSING:
+ return MISSING
+ pattern = re.compile(entry["regex"], re.DOTALL if entry.get("dotall") else 0)
+ group = entry.get("group", 1 if pattern.groups else 0)
+ if entry.get("all"):
+ return [m.group(group) for m in pattern.finditer(text)]
+ m = pattern.search(text)
+ return m.group(group) if m else MISSING
+ if "css" in entry:
+ text = _text_source(entry, source)
+ if text is MISSING:
+ return MISSING
+ key = id(text)
+ if doc_cache is not None and key in doc_cache:
+ doc = doc_cache[key]
+ else:
+ doc = htmlmod.parse(text)
+ if doc_cache is not None:
+ doc_cache[key] = doc
+ found = doc.select(entry["css"])
+ if entry.get("all"):
+ return [_element_value(entry, el) for el in found]
+ if not found:
+ return MISSING
+ return _element_value(entry, found[0])
+ raise ExtractError(f"extract entry {entry.get('into')!r} has no way")
+
+
+def _text_source(entry: dict[str, Any], source: dict[str, Any]) -> Any:
+ frm = entry.get("from", "body")
+ value = resolve(frm, source) if isinstance(frm, str) else MISSING
+ if value is MISSING or value is None:
+ return MISSING
+ return value if isinstance(value, str) else str(value)
+
+
+def _element_value(entry: dict[str, Any], el: htmlmod.Element) -> Any:
+ fields = entry.get("fields")
+ if fields:
+ out: dict[str, Any] = {}
+ for name, spec in fields.items():
+ if isinstance(spec, str):
+ sub = el.select_one(spec)
+ out[name] = sub.text if sub else None
+ else:
+ sub = el.select_one(spec["css"])
+ if sub is None:
+ out[name] = None
+ elif "attr" in spec:
+ out[name] = sub.get(spec["attr"])
+ else:
+ out[name] = sub.text
+ return out
+ if "attr" in entry:
+ return el.get(entry["attr"])
+ return el.text
diff --git a/src/pyrtan/html.py b/src/pyrtan/html.py new file mode 100644 index 0000000..1611e7b --- /dev/null +++ b/src/pyrtan/html.py
@@ -0,0 +1,253 @@
+"""A small HTML document model with a CSS selector subset, on the standard
+library's parser.
+
+The subset is what trees actually need to read a page: tag, ``#id``,
+``.class``, ``[attr]``, ``[attr=value]``, ``[attr^=v]``, ``[attr$=v]``,
+``[attr*=v]``, ``:first-child``, ``:nth-child(n)``, ``:contains(text)``,
+the descendant (space) and child (``>``) combinators, and ``,`` groups.
+Anything else is a :class:`SelectorError` at tree check time, not a silent
+empty match at run time.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from html.parser import HTMLParser
+from typing import Iterator
+
+VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}
+INLINE = {"a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "dfn", "em", "i", "kbd", "label", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "del", "ins"}
+
+
+@dataclass
+class Element:
+ tag: str
+ attrs: dict[str, str] = field(default_factory=dict)
+ children: list["Element | str"] = field(default_factory=list)
+ parent: "Element | None" = field(default=None, repr=False, compare=False)
+
+ @property
+ def text(self) -> str:
+ """All descendant text, whitespace collapsed. Inline elements join
+ their neighbours directly; any other element boundary counts as a
+ space, so two table cells never run together."""
+ parts: list[str] = []
+ for child in self.children:
+ if isinstance(child, str):
+ parts.append(child)
+ elif child.tag in INLINE:
+ parts.append(child.text)
+ else:
+ parts.append(" " + child.text + " ")
+ return re.sub(r"\s+", " ", "".join(parts)).strip()
+
+ @property
+ def classes(self) -> list[str]:
+ return self.attrs.get("class", "").split()
+
+ def get(self, name: str, default: str | None = None) -> str | None:
+ return self.attrs.get(name, default)
+
+ def elements(self) -> Iterator["Element"]:
+ for child in self.children:
+ if isinstance(child, Element):
+ yield child
+
+ def descendants(self) -> Iterator["Element"]:
+ for child in self.elements():
+ yield child
+ yield from child.descendants()
+
+ def select(self, selector: str) -> list["Element"]:
+ return select(self, selector)
+
+ def select_one(self, selector: str) -> "Element | None":
+ found = select(self, selector)
+ return found[0] if found else None
+
+ def to_dict(self) -> dict:
+ return {"tag": self.tag, "attrs": self.attrs, "text": self.text}
+
+
+class _Builder(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.root = Element("document")
+ self.stack = [self.root]
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ el = Element(tag.lower(), {k.lower(): (v if v is not None else "") for k, v in attrs}, parent=self.stack[-1])
+ self.stack[-1].children.append(el)
+ if tag.lower() not in VOID:
+ self.stack.append(el)
+
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ el = Element(tag.lower(), {k.lower(): (v if v is not None else "") for k, v in attrs}, parent=self.stack[-1])
+ self.stack[-1].children.append(el)
+
+ def handle_endtag(self, tag: str) -> None:
+ tag = tag.lower()
+ for i in range(len(self.stack) - 1, 0, -1):
+ if self.stack[i].tag == tag:
+ del self.stack[i:]
+ return
+
+ def handle_data(self, data: str) -> None:
+ if data:
+ self.stack[-1].children.append(data)
+
+
+def parse(text: str) -> Element:
+ b = _Builder()
+ b.feed(text)
+ b.close()
+ return b.root
+
+
+class SelectorError(ValueError):
+ pass
+
+
+_TOKEN = re.compile(
+ r"""
+ (?P<tag>[a-zA-Z][a-zA-Z0-9\-]*|\*)
+ | \#(?P<id>[\w\-]+)
+ | \.(?P<cls>[\w\-]+)
+ | \[(?P<attr>[\w\-:]+)(?:(?P<op>[\^\$\*]?=)(?P<q>["']?)(?P<val>[^\]"']*)(?P=q))?\]
+ | :(?P<pseudo>first-child|last-child|nth-child\(\d+\)|contains\((?P<cq>["']?)(?P<ctext>[^)"']*)(?P=cq)\))
+ """,
+ re.VERBOSE,
+)
+
+
+@dataclass
+class _Simple:
+ tag: str | None = None
+ id: str | None = None
+ classes: list[str] = field(default_factory=list)
+ attrs: list[tuple[str, str, str | None]] = field(default_factory=list)
+ pseudos: list[str] = field(default_factory=list)
+
+ def matches(self, el: Element) -> bool:
+ if self.tag and self.tag != "*" and el.tag != self.tag:
+ return False
+ if self.id and el.attrs.get("id") != self.id:
+ return False
+ if self.classes and not set(self.classes) <= set(el.classes):
+ return False
+ for name, op, val in self.attrs:
+ if name not in el.attrs:
+ return False
+ have = el.attrs[name]
+ if op == "=" and have != val:
+ return False
+ if op == "^=" and not have.startswith(val or ""):
+ return False
+ if op == "$=" and not have.endswith(val or ""):
+ return False
+ if op == "*=" and (val or "") not in have:
+ return False
+ for p in self.pseudos:
+ if not _pseudo(p, el):
+ return False
+ return True
+
+
+def _pseudo(p: str, el: Element) -> bool:
+ if p.startswith("contains:"):
+ return p[len("contains:"):] in el.text
+ siblings = list(el.parent.elements()) if el.parent else [el]
+ if p == "first-child":
+ return siblings[0] is el
+ if p == "last-child":
+ return siblings[-1] is el
+ if p.startswith("nth-child("):
+ n = int(p[len("nth-child("):-1])
+ return 0 < n <= len(siblings) and siblings[n - 1] is el
+ raise SelectorError(f"unknown pseudo-class :{p}")
+
+
+def _parse_compound(text: str) -> _Simple:
+ simple = _Simple()
+ pos = 0
+ text = text.strip()
+ if not text:
+ raise SelectorError("empty selector part")
+ while pos < len(text):
+ m = _TOKEN.match(text, pos)
+ if not m:
+ raise SelectorError(f"cannot parse selector near {text[pos:]!r}")
+ pos = m.end()
+ if m.group("tag"):
+ if simple.tag:
+ raise SelectorError(f"two tags in one compound selector: {text!r}")
+ simple.tag = m.group("tag").lower()
+ elif m.group("id"):
+ simple.id = m.group("id")
+ elif m.group("cls"):
+ simple.classes.append(m.group("cls"))
+ elif m.group("attr"):
+ simple.attrs.append((m.group("attr").lower(), m.group("op") or "", m.group("val")))
+ elif m.group("pseudo"):
+ p = m.group("pseudo")
+ if p.startswith("contains("):
+ simple.pseudos.append("contains:" + m.group("ctext"))
+ else:
+ simple.pseudos.append(p)
+ return simple
+
+
+def compile_selector(selector: str) -> list[list[tuple[str, _Simple]]]:
+ """Compile to groups of (combinator, compound) pairs. Raises on anything
+ outside the supported subset."""
+ groups = []
+ for alt in selector.split(","):
+ alt = alt.strip()
+ if not alt:
+ raise SelectorError(f"empty alternative in {selector!r}")
+ parts = re.split(r"\s*(>)\s*|\s+", alt)
+ chain: list[tuple[str, _Simple]] = []
+ comb = " "
+ for part in parts:
+ if part is None or part == "":
+ continue
+ if part == ">":
+ comb = ">"
+ continue
+ chain.append((comb, _parse_compound(part)))
+ comb = " "
+ if not chain:
+ raise SelectorError(f"nothing to match in {selector!r}")
+ groups.append(chain)
+ return groups
+
+
+def select(root: Element, selector: str) -> list[Element]:
+ compiled = compile_selector(selector)
+ out: list[Element] = []
+ seen: set[int] = set()
+ for el in root.descendants():
+ for chain in compiled:
+ if _chain_matches(el, chain) and id(el) not in seen:
+ seen.add(id(el))
+ out.append(el)
+ break
+ return out
+
+
+def _chain_matches(el: Element, chain: list[tuple[str, _Simple]]) -> bool:
+ comb, simple = chain[-1]
+ if not simple.matches(el):
+ return False
+ if len(chain) == 1:
+ return True
+ rest = chain[:-1]
+ if comb == ">":
+ return el.parent is not None and el.parent.tag != "document" and _chain_matches(el.parent, rest)
+ anc = el.parent
+ while anc is not None and anc.tag != "document":
+ if _chain_matches(anc, rest):
+ return True
+ anc = anc.parent
+ return False
diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py new file mode 100644 index 0000000..8480497 --- /dev/null +++ b/src/pyrtan/hub.py
@@ -0,0 +1,264 @@
+"""The hub: where a swarm's services find each other.
+
+A hub holds no trees. It keeps a list of peers, polls each one's
+``/capabilities`` and ``/health``, and answers the same ``/intent`` and
+``/run`` a service does by forwarding to the peer that holds the matching
+tree. Each peer carries a ``link`` of ``up``, ``stale`` or ``down``: the one
+field an operator reads to know whether the swarm is whole.
+
+Endpoints:
+
+``POST /register`` ``{"url": "http://host:port", "name": "..."}``
+``GET /peers`` every peer with its link and trees
+``GET /capabilities`` the union of every peer's trees
+``GET /health`` ``{"status": "ok", "peers": n, "up": n, "trees": n}``
+``POST /intent`` forwarded to the peer whose tree matches best
+``POST /run`` forwarded to the peer holding ``tree``
+``POST /resume`` forwarded to the peer named in the snapshot
+``GET /gaps`` every peer's gaps, with the peer that saw them
+``GET /`` the demo page; ``GET /api`` is the JSON index
+
+A service that holds several trees and a hub that routes between services
+answer the same requests, so a client need not know which it is talking to,
+and a swarm can start as one process and split later without the callers
+changing.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+from .intent import Trigger, best_matches
+from .page import PAGE
+from .service import Server, http_json
+
+STALE_AFTER = 30.0
+DOWN_AFTER = 90.0
+
+
+@dataclass
+class Peer:
+ url: str
+ name: str = ""
+ trees: list[dict[str, Any]] = field(default_factory=list)
+ last_ok: float = 0.0
+ last_error: str | None = None
+ registered: float = field(default_factory=time.time)
+
+ @property
+ def link(self) -> str:
+ if self.last_ok == 0.0:
+ return "down" if self.last_error else "pending"
+ age = time.time() - self.last_ok
+ if age < STALE_AFTER:
+ return "up"
+ if age < DOWN_AFTER:
+ return "stale"
+ return "down"
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"url": self.url, "name": self.name, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error}
+
+
+class Hub:
+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0) -> None:
+ self.name = name
+ self.poll_every = poll_every
+ self.peers: dict[str, Peer] = {}
+ self.started = time.time()
+ self._lock = threading.RLock()
+ self._stop = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ # ------------------------------------------------------------ peers
+
+ def add_peer(self, url: str, name: str = "") -> Peer:
+ url = url.rstrip("/")
+ with self._lock:
+ peer = self.peers.get(url)
+ if peer is None:
+ peer = Peer(url, name)
+ self.peers[url] = peer
+ elif name:
+ peer.name = name
+ self.refresh(peer)
+ return peer
+
+ def refresh(self, peer: Peer) -> None:
+ try:
+ status, caps = http_json("GET", f"{peer.url}/capabilities", timeout=5.0)
+ except Exception as e: # urllib raises URLError subclasses of OSError
+ status, caps = 0, None
+ peer.last_error = str(e)
+ if status == 200 and isinstance(caps, dict):
+ with self._lock:
+ peer.trees = list(caps.get("trees", []))
+ peer.name = caps.get("name") or peer.name
+ peer.last_ok = time.time()
+ peer.last_error = None
+ elif status:
+ peer.last_error = f"capabilities answered {status}"
+
+ def refresh_all(self) -> None:
+ for peer in list(self.peers.values()):
+ self.refresh(peer)
+
+ def start_polling(self) -> None:
+ def loop() -> None:
+ while not self._stop.wait(self.poll_every):
+ self.refresh_all()
+
+ self._thread = threading.Thread(target=loop, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+
+ # ------------------------------------------------------------ lookup
+
+ def candidates(self) -> list[tuple[tuple[str, str], Trigger]]:
+ out: list[tuple[tuple[str, str], Trigger]] = []
+ with self._lock:
+ for peer in self.peers.values():
+ if peer.link == "down":
+ continue
+ for tree in peer.trees:
+ for pattern in tree.get("triggers", []):
+ try:
+ out.append(((peer.url, tree["name"]), Trigger(pattern)))
+ except Exception:
+ continue
+ return out
+
+ def holder(self, tree_name: str) -> Peer | None:
+ with self._lock:
+ best: Peer | None = None
+ for peer in self.peers.values():
+ if any(t.get("name") == tree_name for t in peer.trees):
+ if best is None or _rank(peer) < _rank(best):
+ best = peer
+ return best
+
+ # ------------------------------------------------------------ handlers
+
+ def health(self) -> dict[str, Any]:
+ with self._lock:
+ peers = list(self.peers.values())
+ return {
+ "status": "ok",
+ "name": self.name,
+ "peers": len(peers),
+ "up": sum(1 for p in peers if p.link == "up"),
+ "stale": sum(1 for p in peers if p.link == "stale"),
+ "down": sum(1 for p in peers if p.link == "down"),
+ "trees": sum(len(p.trees) for p in peers),
+ "uptime_s": int(time.time() - self.started),
+ }
+
+ def capabilities(self) -> dict[str, Any]:
+ trees: list[dict[str, Any]] = []
+ with self._lock:
+ for peer in self.peers.values():
+ for t in peer.trees:
+ trees.append({**t, "peer": peer.url, "link": peer.link})
+ return {"name": self.name, "trees": trees}
+
+ def intent(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ text = body.get("text")
+ if not isinstance(text, str) or not text.strip():
+ return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}
+ matches = best_matches(text, self.candidates()) # type: ignore[arg-type]
+ if not matches:
+ return 200, {"status": "error", "reason": "no-match", "say": f"No service in the swarm answers {text!r}."}
+ tree_names = {m.tree[1] for m in matches} # type: ignore[index]
+ if len(tree_names) > 1:
+ return 200, {"status": "error", "reason": "ambiguous", "say": f"{text!r} matches more than one tree: {sorted(tree_names)}", "output": {"candidates": sorted(tree_names)}}
+ peer_url, tree_name = matches[0].tree # type: ignore[misc]
+ peer = self.holder(tree_name) or self.peers.get(peer_url)
+ return self.forward(peer, "/run", {"tree": tree_name, "inputs": matches[0].inputs, "depth": body.get("depth", 0), "trace": body.get("trace", True)})
+
+ def run(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ tree = body.get("tree")
+ if not isinstance(tree, str):
+ return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"tree\": \"...\", \"inputs\": {...}}."}
+ peer = self.holder(tree)
+ if peer is None:
+ return 200, {"status": "error", "reason": "no-such-tree", "tree": tree, "say": f"No service in the swarm holds {tree!r}."}
+ return self.forward(peer, "/run", body)
+
+ def resume(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ snapshot = body.get("snapshot") or {}
+ peer = self.holder(str(snapshot.get("tree", "")))
+ if peer is None:
+ return 200, {"status": "error", "reason": "no-such-tree", "say": "No service holds the tree this snapshot came from."}
+ return self.forward(peer, "/resume", body)
+
+ def forward(self, peer: Peer | None, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ if peer is None:
+ return 200, {"status": "error", "reason": "no-peer"}
+ try:
+ status, answer = http_json("POST", f"{peer.url}{path}", body, timeout=120.0)
+ except Exception as e:
+ peer.last_error = str(e)
+ return 200, {"status": "error", "reason": "peer-unreachable", "say": f"{peer.name or peer.url} did not answer: {e}", "output": {"peer": peer.url}}
+ if status != 200 or not isinstance(answer, dict):
+ return 200, {"status": "error", "reason": "peer-error", "say": f"{peer.name or peer.url} answered {status}", "output": {"peer": peer.url}}
+ peer.last_ok = time.time()
+ answer.setdefault("peer", peer.url)
+ return 200, answer
+
+ def gaps(self) -> dict[str, Any]:
+ out: list[dict[str, Any]] = []
+ for peer in list(self.peers.values()):
+ try:
+ status, body = http_json("GET", f"{peer.url}/gaps", timeout=5.0)
+ except Exception:
+ continue
+ if status == 200 and isinstance(body, dict):
+ out.extend({**g, "peer": peer.url} for g in body.get("gaps", []))
+ return {"gaps": out}
+
+ def route(self, method: str, path: str, body: dict[str, Any] | None) -> tuple[int, Any]:
+ body = body or {}
+ if method == "GET" and path == "/health":
+ return 200, self.health()
+ if method == "GET" and path == "/peers":
+ with self._lock:
+ return 200, {"peers": [p.to_dict() for p in self.peers.values()]}
+ if method == "GET" and path == "/capabilities":
+ return 200, self.capabilities()
+ if method == "GET" and path == "/gaps":
+ return 200, self.gaps()
+ if method == "GET" and path in ("/", "/index.html"):
+ return 200, PAGE
+ if method == "GET" and path == "/api":
+ return 200, {"pyrtan": "hub", "name": self.name, "endpoints": ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/register"]}
+ if method == "POST" and path == "/register":
+ url = body.get("url")
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):
+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"http://host:port\"}."}
+ peer = self.add_peer(url, str(body.get("name", "")))
+ return 200, {"status": "ok", "peer": peer.to_dict()}
+ if method == "POST" and path == "/intent":
+ return self.intent(body)
+ if method == "POST" and path == "/run":
+ return self.run(body)
+ if method == "POST" and path == "/resume":
+ return self.resume(body)
+ return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}
+
+
+def _rank(peer: Peer) -> int:
+ return {"up": 0, "pending": 1, "stale": 2, "down": 3}[peer.link]
+
+
+def serve_hub(host: str = "127.0.0.1", port: int = 8700, *, name: str = "hub", peers: list[str] | None = None, poll_every: float = 10.0, quiet: bool = False) -> tuple[Hub, Server]:
+ hub = Hub(name=name, poll_every=poll_every)
+ server = Server(hub.route, host, port, quiet)
+ for url in peers or []:
+ hub.add_peer(url)
+ hub.start_polling()
+ return hub, server
diff --git a/src/pyrtan/intent.py b/src/pyrtan/intent.py new file mode 100644 index 0000000..5684231 --- /dev/null +++ b/src/pyrtan/intent.py
@@ -0,0 +1,164 @@
+"""Triggers: how a sentence finds the tree that answers it.
+
+A trigger is a pattern with slots::
+
+ "weather in {city}"
+ "top {count:int} stories on hacker news"
+ "is {host:word} up"
+ "what's [the] latest release of {repo}"
+
+A slot is ``{name}`` (any text), ``{name:int}``, ``{name:float}`` or
+``{name:word}`` (no spaces). Square brackets mark optional words, round
+brackets a required choice, and ``|`` separates alternatives inside either:
+``"how (hot|cold) is it in {place}"``. Matching is
+case-insensitive, ignores surrounding punctuation, and collapses whitespace.
+
+When several triggers match, the most specific wins: the one with the most
+literal text. A tie is reported as ambiguity rather than resolved by luck.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+SLOT = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)(?::(int|float|word|text))?\}")
+SLOT_RE = {
+ "int": r"-?\d+",
+ "float": r"-?\d+(?:\.\d+)?",
+ "word": r"\S+",
+ "text": r".+?",
+ None: r".+?",
+}
+CAST = {"int": int, "float": float, "word": str, "text": str, None: str}
+
+
+class TriggerError(ValueError):
+ pass
+
+
+def normalise(text: str) -> str:
+ text = text.strip()
+ text = re.sub(r"^[\s\W_]+|[\s?!.,;:]+$", "", text)
+ text = re.sub(r"\s+", " ", text)
+ return text
+
+
+@dataclass
+class Trigger:
+ pattern: str
+ fixed_inputs: dict[str, Any] = field(default_factory=dict)
+ regex: re.Pattern[str] = field(init=False, repr=False)
+ slots: dict[str, str | None] = field(init=False, default_factory=dict)
+ specificity: int = field(init=False, default=0)
+
+ def __post_init__(self) -> None:
+ self.regex, self.slots, self.specificity = compile_pattern(self.pattern)
+
+ def match(self, text: str) -> dict[str, Any] | None:
+ m = self.regex.fullmatch(normalise(text))
+ if not m:
+ return None
+ inputs: dict[str, Any] = dict(self.fixed_inputs)
+ for name, kind in self.slots.items():
+ raw = m.group(name)
+ if raw is None:
+ continue
+ try:
+ inputs[name] = CAST[kind](raw.strip())
+ except ValueError:
+ return None
+ return inputs
+
+
+def compile_pattern(pattern: str) -> tuple[re.Pattern[str], dict[str, str | None], int]:
+ if not pattern or not pattern.strip():
+ raise TriggerError("a trigger pattern cannot be empty")
+ slots: dict[str, str | None] = {}
+ out: list[str] = []
+ literal_chars = 0
+ pos = 0
+ depth = 0
+ text = pattern.strip().lower()
+ while pos < len(text):
+ ch = text[pos]
+ if ch == "{":
+ m = SLOT.match(text, pos)
+ if not m:
+ raise TriggerError(f"bad slot in trigger {pattern!r} at {pos}")
+ name, kind = m.group(1), m.group(2)
+ if name in slots:
+ raise TriggerError(f"slot {name!r} appears twice in {pattern!r}")
+ slots[name] = kind
+ out.append(f"(?P<{name}>{SLOT_RE[kind]})")
+ pos = m.end()
+ elif ch == "[":
+ depth += 1
+ out.append(r"(?:\s*")
+ pos += 1
+ elif ch == "]":
+ if depth == 0:
+ raise TriggerError(f"unbalanced ] in trigger {pattern!r}")
+ depth -= 1
+ out.append(r")?")
+ pos += 1
+ elif ch == "(":
+ depth += 1
+ out.append(r"(?:")
+ pos += 1
+ elif ch == ")":
+ if depth == 0:
+ raise TriggerError(f"unbalanced ) in trigger {pattern!r}")
+ depth -= 1
+ out.append(r")")
+ pos += 1
+ elif ch == "|" and depth > 0:
+ out.append("|")
+ pos += 1
+ elif ch.isspace():
+ while pos < len(text) and text[pos].isspace():
+ pos += 1
+ # Space is optional around optional groups and slots so that
+ # "[the] weather" matches "weather" without a leading space.
+ if out and out[-1] in (r"(?:\s*", r")?", r"(?:", r")", "|"):
+ out.append(r"\s*")
+ elif pos < len(text) and text[pos] in "[(|":
+ out.append(r"\s*")
+ else:
+ out.append(r"\s+")
+ else:
+ out.append(re.escape(ch))
+ literal_chars += 1
+ pos += 1
+ if depth != 0:
+ raise TriggerError(f"unbalanced [ in trigger {pattern!r}")
+ if literal_chars == 0:
+ raise TriggerError(f"trigger {pattern!r} has no literal words; it would match anything")
+ try:
+ regex = re.compile("".join(out), re.IGNORECASE)
+ except re.error as e:
+ raise TriggerError(f"trigger {pattern!r} does not compile: {e}") from e
+ return regex, slots, literal_chars
+
+
+@dataclass
+class Match:
+ tree: str
+ trigger: Trigger
+ inputs: dict[str, Any]
+ specificity: int
+
+
+def best_matches(text: str, candidates: list[tuple[str, Trigger]]) -> list[Match]:
+ """All matches at the highest specificity. One item is a clear winner;
+ several is ambiguity; none is no match."""
+ matches: list[Match] = []
+ for tree, trig in candidates:
+ inputs = trig.match(text)
+ if inputs is not None:
+ matches.append(Match(tree, trig, inputs, trig.specificity))
+ if not matches:
+ return []
+ top = max(m.specificity for m in matches)
+ return [m for m in matches if m.specificity == top]
diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py new file mode 100644 index 0000000..17edb61 --- /dev/null +++ b/src/pyrtan/page.py
@@ -0,0 +1,128 @@
+"""The demo page: one HTML file a service or hub serves at ``/``.
+
+It lists what the swarm can answer, takes a sentence, posts it to
+``/intent`` on the same origin, and shows the answer, the output and the
+trace. No external assets, no build step; it exists so that someone can
+point a browser at a running Pyrtan and understand it in a minute.
+"""
+
+from __future__ import annotations
+
+PAGE = r"""<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Pyrtan</title>
+<style>
+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --accent:#2b5db8; }
+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --accent:#7fa6f0; } }
+ * { box-sizing:border-box }
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }
+ main { max-width:880px; margin:0 auto; padding:32px 16px 64px }
+ h1 { font-size:22px; margin:0 0 4px } h1 small { color:var(--mute); font-weight:normal; font-size:14px; margin-left:8px }
+ p.lede { color:var(--mute); margin:0 0 24px }
+ form { display:flex; gap:8px; margin:0 0 16px }
+ input[type=text] { flex:1; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
+ button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:#fff; cursor:pointer }
+ button:disabled { opacity:.6; cursor:wait }
+ .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 14px }
+ .status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)}
+ .say { font-size:18px; margin:6px 0 }
+ pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap }
+ details summary { cursor:pointer; color:var(--mute) }
+ ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px }
+ ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px }
+ table { width:100%; border-collapse:collapse; font-size:14px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 }
+ code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace }
+ .mute { color:var(--mute) } .gap { color:var(--ask) }
+ footer { color:var(--mute); font-size:13px; margin-top:32px }
+</style>
+</head>
+<body>
+<main>
+ <h1>Pyrtan <small id="who">…</small></h1>
+ <p class="lede">A swarm of small services that act on the web through hardcoded, inspectable decision trees. Type a sentence a tree answers to; the answer comes back with the exact path that produced it. No model is involved.</p>
+
+ <form id="ask">
+ <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off">
+ <button type="submit" id="go">Ask</button>
+ </form>
+ <div class="card" style="margin-bottom:24px">
+ <div class="mute" style="margin-bottom:8px">Try one</div>
+ <ul class="tries" id="tries"></ul>
+ </div>
+
+ <div id="out"></div>
+
+ <h2 style="font-size:17px;margin:32px 0 8px">What this swarm can answer</h2>
+ <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div>
+
+ <footer>The same endpoints the page uses: <code>POST /intent {"text": "…"}</code>, <code>POST /run {"tree": "…", "inputs": {…}}</code>, <code>GET /capabilities</code>, <code>GET /health</code>, <code>GET /gaps</code>. <a href="https://github.com/b-i-t-8/pyrtan">Source</a>.</footer>
+</main>
+<script>
+const $ = (s) => document.querySelector(s);
+const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+const examples = {
+ "weather": "what's the weather in Oslo",
+ "hacker-news": "top 3 stories on hacker news",
+ "site-status": "is example.com up",
+ "github-release": "latest release of astral-sh/uv",
+ "hacker-news-item": "hacker news item 1",
+};
+function sample(t) {
+ if (examples[t.name]) return examples[t.name];
+ const pat = (t.triggers || [])[0]; if (!pat) return null;
+ return pat.replace(/\[[^\]]*\]/g, "").replace(/\((\w+)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
+}
+async function load() {
+ try {
+ const h = await (await fetch("health")).json();
+ $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up, ${h.trees} trees` : `${(h.trees||[]).length} trees`);
+ } catch (e) { $("#who").textContent = "unreachable"; }
+ const c = await (await fetch("capabilities")).json();
+ const body = $("#caps tbody"); const tries = $("#tries");
+ for (const t of c.trees || []) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `<td><code>${esc(t.name)}</code><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div>${esc(t.peer)} · ${esc(t.link||"")}</div>` : ""}</td>`;
+ body.appendChild(tr);
+ const s = sample(t); if (s && !s.includes("…")) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); }
+ }
+ tries.addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; ask(); });
+}
+function renderTrace(tr) {
+ if (!tr || !tr.steps) return "";
+ const lines = tr.steps.map(s => `${" ".repeat((s.depth||0)+1)}${s.node} (${s.kind})${s.chosen ? " [" + s.chosen + "]" : ""}: ${s.summary}${s.next ? " -> " + s.next : ""}`);
+ for (const g of tr.gaps || []) lines.push(` gap: ${g.node} saw ${g.on} = ${JSON.stringify(g.observed)} and had no branch for it`);
+ return `trace: ${tr.tree}\n` + lines.join("\n");
+}
+async function ask(snapshot, answer) {
+ const text = $("#text").value.trim(); if (!text && !snapshot) return;
+ $("#go").disabled = true;
+ const t0 = performance.now();
+ let r;
+ try {
+ const res = snapshot ? await fetch("resume", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({snapshot, answer})})
+ : await fetch("intent", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({text})});
+ r = await res.json();
+ } catch (e) { r = {status:"error", reason:"unreachable", say:String(e)}; }
+ const ms = Math.round(performance.now() - t0);
+ const out = $("#out");
+ const card = document.createElement("div"); card.className = "card";
+ let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}</span></div>`;
+ if (r.say) html += `<div class="say">${esc(r.say)}</div>`;
+ if (r.status === "asked") html += `<form class="resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`;
+ if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`;
+ if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`;
+ card.innerHTML = html;
+ out.prepend(card);
+ const rf = card.querySelector("form.resume");
+ if (rf) rf.addEventListener("submit", (e) => { e.preventDefault(); ask(r.snapshot, rf.querySelector("input").value); });
+ $("#go").disabled = false;
+}
+$("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); });
+load();
+</script>
+</body>
+</html>
+"""
diff --git a/src/pyrtan/registry.py b/src/pyrtan/registry.py new file mode 100644 index 0000000..7c3c56f --- /dev/null +++ b/src/pyrtan/registry.py
@@ -0,0 +1,158 @@
+"""The trees a service knows about, and how a sentence finds one.
+
+A registry loads every ``*.toml`` under one or more directories, reloads a
+file when its modification time changes (so trees can be edited while a
+service runs), and matches intents against the triggers of everything
+loaded.
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Iterable
+
+from .intent import Match, Trigger, best_matches
+from .tree import Tree, TreeError, load_tree
+
+
+class Ambiguous(LookupError):
+ def __init__(self, text: str, matches: list[Match]) -> None:
+ self.text = text
+ self.matches = matches
+ names = ", ".join(f"{m.tree} ({m.trigger.pattern!r})" for m in matches)
+ super().__init__(f"{text!r} matches more than one tree equally well: {names}")
+
+
+class NoMatch(LookupError):
+ pass
+
+
+@dataclass
+class _Loaded:
+ tree: Tree
+ path: Path | None
+ mtime: float
+
+
+@dataclass
+class Registry:
+ directories: list[Path] = field(default_factory=list)
+ _trees: dict[str, _Loaded] = field(default_factory=dict, init=False, repr=False)
+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
+ errors: dict[str, str] = field(default_factory=dict, init=False)
+
+ @classmethod
+ def from_paths(cls, paths: Iterable[str | Path]) -> "Registry":
+ reg = cls()
+ for p in paths:
+ p = Path(p)
+ if p.is_dir():
+ reg.directories.append(p)
+ else:
+ reg.add_file(p)
+ reg.reload()
+ return reg
+
+ # ------------------------------------------------------------ loading
+
+ def add(self, tree: Tree) -> None:
+ with self._lock:
+ self._trees[tree.name] = _Loaded(tree, None, 0.0)
+
+ def add_file(self, path: str | Path) -> Tree:
+ path = Path(path)
+ tree = load_tree(path)
+ with self._lock:
+ self._trees[tree.name] = _Loaded(tree, path, path.stat().st_mtime)
+ self.errors.pop(str(path), None)
+ return tree
+
+ def reload(self) -> None:
+ """Load new files, reload changed ones, drop deleted ones."""
+ with self._lock:
+ seen: set[Path] = set()
+ for d in self.directories:
+ for path in sorted(d.rglob("*.toml")):
+ if path.name == "pyproject.toml":
+ continue
+ seen.add(path)
+ try:
+ mtime = path.stat().st_mtime
+ except OSError:
+ continue
+ current = next((l for l in self._trees.values() if l.path == path), None)
+ if current is not None and current.mtime == mtime:
+ continue
+ try:
+ tree = load_tree(path)
+ except TreeError as e:
+ self.errors[str(path)] = str(e)
+ continue
+ self.errors.pop(str(path), None)
+ if current is not None and current.tree.name != tree.name:
+ del self._trees[current.tree.name]
+ self._trees[tree.name] = _Loaded(tree, path, mtime)
+ for name, loaded in list(self._trees.items()):
+ if loaded.path is not None and loaded.path not in seen and any(loaded.path.is_relative_to(d) for d in self.directories):
+ if not loaded.path.exists():
+ del self._trees[name]
+
+ # ------------------------------------------------------------ lookup
+
+ def names(self) -> list[str]:
+ with self._lock:
+ return sorted(self._trees)
+
+ def trees(self) -> list[Tree]:
+ with self._lock:
+ return [l.tree for _, l in sorted(self._trees.items())]
+
+ def get(self, name: str) -> Tree | None:
+ with self._lock:
+ loaded = self._trees.get(name)
+ return loaded.tree if loaded else None
+
+ def __contains__(self, name: object) -> bool:
+ return isinstance(name, str) and self.get(name) is not None
+
+ def __len__(self) -> int:
+ return len(self._trees)
+
+ def candidates(self) -> list[tuple[str, Trigger]]:
+ return [(t.name, trig) for t in self.trees() for trig in t.triggers]
+
+ def match(self, text: str) -> Match:
+ """The one tree that answers ``text``, or :class:`NoMatch` /
+ :class:`Ambiguous`."""
+ matches = best_matches(text, self.candidates())
+ if not matches:
+ raise NoMatch(f"no tree answers {text!r}")
+ if len(matches) > 1:
+ # Same tree matched twice at equal specificity: take the first.
+ if len({m.tree for m in matches}) == 1:
+ return matches[0]
+ raise Ambiguous(text, matches)
+ return matches[0]
+
+ def capabilities(self) -> list[dict[str, Any]]:
+ return [t.capability() for t in self.trees()]
+
+ def gather(self, root: str | Path) -> int:
+ """Add a directory and load it. Returns how many trees are loaded."""
+ root = Path(root)
+ if root not in self.directories:
+ self.directories.append(root)
+ self.reload()
+ return len(self)
+
+
+def default_paths() -> list[Path]:
+ """Where ``pyrtan`` looks when no path is given: ``$PYRTAN_TREES`` (colon
+ separated) or ``./trees``."""
+ env = os.environ.get("PYRTAN_TREES")
+ if env:
+ return [Path(p) for p in env.split(os.pathsep) if p]
+ return [Path("trees")]
diff --git a/src/pyrtan/runtime.py b/src/pyrtan/runtime.py new file mode 100644 index 0000000..8d480a8 --- /dev/null +++ b/src/pyrtan/runtime.py
@@ -0,0 +1,409 @@
+"""Executing a tree.
+
+The :class:`Runner` walks a tree from ``start`` to a terminal node, keeping a
+state table, a trace of every step, and the tree's host allow-list in front
+of every request. It returns an :class:`Outcome`.
+
+An outcome's ``status`` is one of:
+
+``done``
+ A ``done`` node was reached; ``say`` and ``output`` are set.
+``failed``
+ A ``fail`` node was reached; ``reason`` names which, and ``say`` explains.
+``asked``
+ An ``ask`` node paused the run; ``question`` holds the prompt and
+ ``snapshot`` what :meth:`Runner.resume` needs to continue.
+``error``
+ The run could not proceed: a transport failure, an unhandled observation
+ (a ``decide`` with no matching branch, recorded as a gap), a host outside
+ the allow-list, a missing template value, or a limit reached.
+
+Nothing here calls a model. The runner is the whole of Pyrtan's "thinking";
+the trace is the whole of its explanation.
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+import time
+from dataclasses import dataclass, field
+from typing import Any, Protocol
+
+from . import conditions
+from .extract import extract
+from .registry import Ambiguous, NoMatch, Registry
+from .template import MISSING, TemplateError, render, resolve
+from .trace import Step, Trace
+from .transport import Transport, TransportError, UrllibTransport
+from .tree import Node, Tree, TreeError
+
+MAX_STEPS = 500
+MAX_DEPTH = 8
+DEFAULT_TIMEOUT = 10.0
+DEFAULT_RETRIES = 1
+
+
+class Resolver(Protocol):
+ """Where ``delegate`` and ``each ... tree`` go when a tree is not local."""
+
+ def run_tree(self, name: str, inputs: dict[str, Any], depth: int) -> "Outcome": ...
+
+ def run_intent(self, text: str, depth: int) -> "Outcome": ...
+
+
+@dataclass
+class Outcome:
+ status: str
+ tree: str
+ say: str | None = None
+ output: Any = None
+ reason: str | None = None
+ question: str | None = None
+ snapshot: dict[str, Any] | None = None
+ trace: Trace | None = None
+ inputs: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def ok(self) -> bool:
+ return self.status == "done"
+
+ def to_dict(self, *, with_trace: bool = True) -> dict[str, Any]:
+ d: dict[str, Any] = {"status": self.status, "tree": self.tree, "inputs": self.inputs}
+ if self.say is not None:
+ d["say"] = self.say
+ if self.output is not None:
+ d["output"] = self.output
+ if self.reason is not None:
+ d["reason"] = self.reason
+ if self.question is not None:
+ d["question"] = self.question
+ if self.snapshot is not None:
+ d["snapshot"] = self.snapshot
+ if with_trace and self.trace is not None:
+ d["trace"] = self.trace.to_dict()
+ return d
+
+
+class _Halt(Exception):
+ def __init__(self, outcome: Outcome) -> None:
+ self.outcome = outcome
+
+
+@dataclass
+class Runner:
+ registry: Registry
+ transport: Transport = field(default_factory=UrllibTransport)
+ resolver: Resolver | None = None
+ env: dict[str, Any] = field(default_factory=dict)
+ max_steps: int = MAX_STEPS
+ max_depth: int = MAX_DEPTH
+ emitted: list[dict[str, Any]] = field(default_factory=list)
+
+ # ------------------------------------------------------------ entry points
+
+ def run_intent(self, text: str, depth: int = 0) -> Outcome:
+ try:
+ match = self.registry.match(text)
+ except NoMatch:
+ if self.resolver is not None:
+ return self.resolver.run_intent(text, depth)
+ return Outcome("error", "", reason="no-match", say=f"Nothing here answers {text!r}.")
+ except Ambiguous as e:
+ return Outcome("error", "", reason="ambiguous", say=str(e), output={"candidates": [m.tree for m in e.matches]})
+ return self.run_tree(match.tree, match.inputs, depth)
+
+ def run_tree(self, name: str, inputs: dict[str, Any] | None = None, depth: int = 0) -> Outcome:
+ tree = self.registry.get(name)
+ if tree is None:
+ if self.resolver is not None:
+ return self.resolver.run_tree(name, inputs or {}, depth)
+ return Outcome("error", name, reason="no-such-tree", say=f"No tree named {name!r}.")
+ return self.run(tree, inputs or {}, depth=depth)
+
+ def run(self, tree: Tree, inputs: dict[str, Any] | None = None, *, depth: int = 0, start: str | None = None, state: dict[str, Any] | None = None, scope_extra: dict[str, Any] | None = None, trace: Trace | None = None) -> Outcome:
+ trace = trace or Trace(tree.name)
+ if depth > self.max_depth:
+ return Outcome("error", tree.name, reason="too-deep", say=f"Delegation deeper than {self.max_depth} levels.", trace=trace)
+ try:
+ bound = tree.bind_inputs(inputs or {})
+ except TreeError as e:
+ return Outcome("error", tree.name, reason="bad-input", say=str(e), trace=trace)
+ state = dict(state) if state is not None else {}
+ scope: dict[str, Any] = {"inputs": bound, "state": state, "tree": {"name": tree.name, "version": tree.version}, "now": _now(), "env": self.env}
+ if scope_extra:
+ scope.update(scope_extra)
+ try:
+ return self._walk(tree, start or tree.start, scope, trace, depth)
+ except _Halt as h:
+ h.outcome.inputs = bound
+ h.outcome.trace = h.outcome.trace or trace
+ return h.outcome
+
+ def resume(self, snapshot: dict[str, Any], answer: Any) -> Outcome:
+ """Continue a run paused by ``ask``. The snapshot is what the asked
+ outcome carried; the answer goes where the ask node said."""
+ tree = self.registry.get(snapshot["tree"])
+ if tree is None:
+ return Outcome("error", snapshot.get("tree", ""), reason="no-such-tree")
+ node = tree.node(snapshot["node"])
+ into: str = node.get("into")
+ inputs = dict(snapshot["inputs"])
+ state = dict(snapshot["state"])
+ if into.startswith("inputs."):
+ key = into[len("inputs."):]
+ try:
+ inputs[key] = tree.inputs[key].coerce(answer)
+ except TreeError as e:
+ return Outcome("error", tree.name, reason="bad-input", say=str(e))
+ else:
+ _put(state, into[len("state."):] if into.startswith("state.") else into, answer)
+ trace = Trace(tree.name)
+ trace.steps = [Step(**s) for s in snapshot.get("trace", [])]
+ trace.add(Step(node.id, "ask", f"answered: {_brief(answer)}", next=node.next))
+ return self.run(tree, inputs, start=node.next, state=state, trace=trace)
+
+ # ------------------------------------------------------------ the walk
+
+ def _walk(self, tree: Tree, start: str, scope: dict[str, Any], trace: Trace, depth: int) -> Outcome:
+ state: dict[str, Any] = scope["state"]
+ node_id = start
+ steps = 0
+ while True:
+ steps += 1
+ if steps > self.max_steps:
+ raise _Halt(Outcome("error", tree.name, reason="too-many-steps", say=f"More than {self.max_steps} steps.", trace=trace))
+ node = tree.node(node_id)
+ started = time.monotonic()
+ handler = getattr(self, f"_do_{node.kind}")
+ try:
+ result = handler(tree, node, scope, trace, depth)
+ except TemplateError as e:
+ trace.add(Step(node.id, node.kind, f"template: {e}", depth=depth))
+ raise _Halt(Outcome("error", tree.name, reason="template", say=f"{node.id}: {e}", trace=trace))
+ if isinstance(result, Outcome):
+ result.trace = trace
+ raise _Halt(result)
+ step, next_id = result
+ step.elapsed_ms = int((time.monotonic() - started) * 1000)
+ step.depth = depth
+ step.next = next_id
+ trace.add(step)
+ if next_id is None:
+ raise _Halt(Outcome("error", tree.name, reason="dead-end", say=f"{node.id} has nowhere to go.", trace=trace))
+ node_id = next_id
+
+ # ------------------------------------------------------------ node kinds
+
+ def _do_observe(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ return self._observe_or_act(tree, node, scope, trace, retry=True)
+
+ def _do_act(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ emit = node.get("emit")
+ if emit is not None:
+ event = {"event": render(emit["event"], scope), "data": render(emit.get("data", {}), scope), "tree": tree.name}
+ self.emitted.append(event)
+ _put(scope["state"], node.get("into", "last"), event)
+ return Step(node.id, "act", f"emitted {event['event']}", observed=event["data"]), node.next
+ return self._observe_or_act(tree, node, scope, trace, retry=False)
+
+ def _observe_or_act(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, *, retry: bool):
+ state = scope["state"]
+ into = node.get("into", "last")
+ http = node.get("http")
+ if http is not None:
+ req = render(http, scope)
+ method = str(req.get("method", "GET")).upper()
+ url = str(req["url"])
+ if not tree.host_allowed(url):
+ return Outcome("error", tree.name, reason="host-not-allowed", say=f"{node.id}: {url} is outside this tree's hosts {tree.hosts}.")
+ headers = {str(k): str(v) for k, v in (req.get("headers") or {}).items()}
+ body = req.get("body")
+ if body is not None and not isinstance(body, str):
+ import json as _json
+
+ body = _json.dumps(body)
+ headers.setdefault("Content-Type", "application/json")
+ timeout = float(node.get("timeout", DEFAULT_TIMEOUT))
+ attempts = 1 + int(node.get("retries", DEFAULT_RETRIES)) if retry else 1
+ last_error: str | None = None
+ for attempt in range(attempts):
+ try:
+ resp = self.transport.request(method, url, headers, body, timeout)
+ break
+ except TransportError as e:
+ last_error = str(e)
+ if attempt + 1 < attempts:
+ time.sleep(min(0.2 * (attempt + 1), 1.0))
+ else:
+ trace.add(Step(node.id, node.kind, f"{method} {url} failed: {last_error}"))
+ return Outcome("error", tree.name, reason="transport", say=f"{node.id}: {last_error}")
+ observed = resp.to_state()
+ _put(state, into, observed)
+ source = observed
+ summary = f"{method} {url} -> {resp.status}"
+ else:
+ frm = node.get("from")
+ source_val = resolve(_state_path(frm), scope)
+ if source_val is MISSING:
+ source = {}
+ elif isinstance(source_val, dict):
+ source = source_val
+ else:
+ source = {"value": source_val, "body": source_val if isinstance(source_val, str) else None}
+ summary = f"from {frm}"
+ extracted: dict[str, Any] = {}
+ cache: dict[int, Any] = {}
+ for entry in node.get("extract", []) or []:
+ value = extract(entry, source, cache)
+ if value is MISSING:
+ extracted[entry["into"]] = None
+ else:
+ _put(state, entry["into"], value)
+ extracted[entry["into"]] = value
+ if extracted:
+ summary += "; " + ", ".join(f"{k}={_brief(v)}" for k, v in extracted.items())
+ return Step(node.id, node.kind, summary, observed=extracted or None), node.next
+
+ def _do_decide(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ on = _state_path(node.get("on"))
+ value = resolve(on, scope)
+ for i, br in enumerate(node.get("branches")):
+ if "else" in br:
+ return Step(node.id, "decide", f"{on} = {_brief(value)}", observed=_plain(value), chosen="else"), br["else"]
+ if conditions.holds(br["when"], value):
+ return Step(node.id, "decide", f"{on} = {_brief(value)}", observed=_plain(value), chosen=_cond_text(br["when"])), br["then"]
+ trace.gap(node.id, on, _plain(value))
+ trace.add(Step(node.id, "decide", f"{on} = {_brief(value)}: no branch", observed=_plain(value), depth=depth))
+ return Outcome("error", tree.name, reason="gap", say=f"{node.id}: no branch for {on} = {_brief(value)}.", output={"gap": trace.gaps[-1]})
+
+ def _do_set(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ state = scope["state"]
+ values = {}
+ for key, tmpl in node.get("set").items():
+ value = render(tmpl, scope)
+ _put(state, key, value)
+ values[key] = value
+ return Step(node.id, "set", ", ".join(f"{k}={_brief(v)}" for k, v in values.items()), observed=values), node.next
+
+ def _do_each(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ state = scope["state"]
+ items = resolve(_state_path(node.get("over")), scope)
+ if items is MISSING or items is None:
+ items = []
+ if not isinstance(items, list):
+ return Outcome("error", tree.name, reason="not-a-list", say=f"{node.id}: {node.get('over')} is not a list.")
+ limit = node.get("limit")
+ if limit is not None:
+ limit = int(render(limit, scope))
+ items = items[:limit]
+ results: list[Any] = []
+ failures = 0
+ for index, item in enumerate(items):
+ with_inputs = render(node.get("with", {}), {**scope, "item": item, "index": index})
+ if "tree" in node.spec:
+ sub = self.run_tree(node.get("tree"), with_inputs, depth + 1)
+ else:
+ sub = self.run(tree, scope["inputs"], depth=depth + 1, start=node.get("start"), state=state, scope_extra={"item": item, "index": index}, trace=trace)
+ if sub.status == "done":
+ results.append(sub.output if sub.output is not None else sub.say)
+ else:
+ failures += 1
+ results.append(None)
+ if node.get("stop_on_fail", False):
+ return Outcome("error", tree.name, reason="each-failed", say=f"{node.id}: item {index} failed: {sub.say or sub.reason}")
+ if sub.trace is not None and sub.trace is not trace:
+ for s in sub.trace.steps:
+ s.depth = depth + 1
+ trace.steps.append(s)
+ trace.gaps.extend(sub.trace.gaps)
+ _put(state, node.get("into"), results)
+ summary = f"{len(items)} item(s)" + (f", {failures} failed" if failures else "")
+ return Step(node.id, "each", summary), node.next
+
+ def _do_delegate(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ state = scope["state"]
+ with_inputs = render(node.get("with", {}), scope)
+ if "intent" in node.spec:
+ text = render(node.get("intent"), scope)
+ sub = self.run_intent(str(text), depth + 1)
+ summary = f"intent {text!r} -> {sub.tree or '?'}: {sub.status}"
+ else:
+ sub = self.run_tree(node.get("tree"), with_inputs, depth + 1)
+ summary = f"tree {node.get('tree')}: {sub.status}"
+ if sub.trace is not None and sub.trace is not trace:
+ for s in sub.trace.steps:
+ s.depth = depth + 1
+ trace.steps.append(s)
+ trace.gaps.extend(sub.trace.gaps)
+ _put(state, node.get("into"), {"status": sub.status, "say": sub.say, "output": sub.output, "reason": sub.reason, "tree": sub.tree})
+ if sub.status == "asked":
+ return Outcome("error", tree.name, reason="ask-in-delegate", say=f"{node.id}: {sub.tree} asked a question; delegated trees cannot pause.")
+ return Step(node.id, "delegate", summary), node.next
+
+ def _do_ask(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ if depth > 0:
+ return Outcome("error", tree.name, reason="ask-in-subflow", say=f"{node.id}: only a top-level run can ask.")
+ prompt = render(node.get("prompt"), scope)
+ trace.add(Step(node.id, "ask", f"asked: {prompt}", depth=depth))
+ snapshot = {"tree": tree.name, "node": node.id, "inputs": scope["inputs"], "state": scope["state"], "trace": [s.to_dict() for s in trace.steps]}
+ return Outcome("asked", tree.name, question=str(prompt), snapshot=snapshot)
+
+ def _do_done(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ say = node.get("say")
+ output = node.get("output")
+ say = render(say, scope, strict=False) if say is not None else None
+ output = render(output, scope, strict=False) if output is not None else None
+ trace.add(Step(node.id, "done", str(say) if say else "done", depth=depth))
+ return Outcome("done", tree.name, say=str(say) if say is not None else None, output=output)
+
+ def _do_fail(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):
+ say = node.get("say")
+ say = render(say, scope, strict=False) if say is not None else None
+ reason = str(node.get("reason"))
+ trace.add(Step(node.id, "fail", f"{reason}: {say or ''}".rstrip(": "), depth=depth))
+ return Outcome("failed", tree.name, reason=reason, say=str(say) if say is not None else None, output=render(node.get("output"), scope, strict=False) if node.get("output") is not None else None)
+
+
+# ---------------------------------------------------------------- helpers
+
+
+def _state_path(path: str) -> str:
+ path = path.strip()
+ head = path.split(".")[0].split("[")[0]
+ if head in ("inputs", "state", "item", "index", "tree", "now", "env"):
+ return path
+ return "state." + path
+
+
+def _put(state: dict[str, Any], path: str, value: Any) -> None:
+ if path.startswith("state."):
+ path = path[len("state."):]
+ parts = path.split(".")
+ cur = state
+ for part in parts[:-1]:
+ nxt = cur.get(part)
+ if not isinstance(nxt, dict):
+ nxt = {}
+ cur[part] = nxt
+ cur = nxt
+ cur[parts[-1]] = value
+
+
+def _plain(value: Any) -> Any:
+ return None if value is MISSING else value
+
+
+def _brief(value: Any, limit: int = 80) -> str:
+ if value is MISSING:
+ return "<missing>"
+ text = repr(value) if not isinstance(value, str) else value
+ return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def _cond_text(cond: dict[str, Any]) -> str:
+ return " and ".join(f"{k} {v!r}" for k, v in cond.items())
+
+
+def _now() -> dict[str, Any]:
+ now = _dt.datetime.now(_dt.timezone.utc)
+ return {"iso": now.isoformat(timespec="seconds"), "date": now.date().isoformat(), "epoch": int(now.timestamp()), "year": now.year}
diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py new file mode 100644 index 0000000..f641f1d --- /dev/null +++ b/src/pyrtan/service.py
@@ -0,0 +1,262 @@
+"""One member of the swarm: an HTTP service hosting a registry of trees.
+
+Endpoints, all JSON:
+
+``GET /health``
+ ``{"status": "ok", "name": ..., "trees": [...], "errors": {...}}``
+``GET /capabilities``
+ What this service can do: every tree's name, triggers and inputs.
+``POST /intent`` ``{"text": "..."}``
+ Match a sentence to a tree and run it.
+``POST /run`` ``{"tree": "...", "inputs": {...}}``
+ Run a named tree.
+``POST /resume`` ``{"snapshot": {...}, "answer": ...}``
+ Continue a run that asked a question.
+``GET /gaps``
+ Decisions that met a value no branch covered, since the service started.
+ Each is a branch waiting to be written.
+``GET /``
+ The demo page (:mod:`pyrtan.page`); ``GET /api`` is the JSON index.
+
+A service may be given a hub (``--hub``). Then it registers itself on start,
+and a ``delegate`` to a tree it does not hold is forwarded to the hub.
+
+The server is the standard library's threading HTTP server. It is meant for
+a swarm of small processes on a trusted network, behind whatever ingress
+the operator already runs; it does not do TLS or authentication itself.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+import urllib.error
+import urllib.request
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any, Callable
+
+from .page import PAGE
+from .registry import Registry
+from .runtime import Outcome, Runner
+from .trace import Trace
+from .transport import Transport, UrllibTransport
+
+MAX_BODY = 1 << 20
+
+
+def http_json(method: str, url: str, payload: Any = None, timeout: float = 15.0) -> tuple[int, Any]:
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
+ req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", "Accept": "application/json"})
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.status, json.loads(resp.read().decode("utf-8") or "null")
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode("utf-8") or "null")
+ except Exception:
+ return e.code, None
+
+
+class RemoteResolver:
+ """Sends what a local runner cannot answer to a hub or another service."""
+
+ def __init__(self, base_url: str) -> None:
+ self.base_url = base_url.rstrip("/")
+
+ def run_tree(self, name: str, inputs: dict[str, Any], depth: int) -> Outcome:
+ status, body = http_json("POST", f"{self.base_url}/run", {"tree": name, "inputs": inputs, "depth": depth})
+ return outcome_from_dict(body, name) if isinstance(body, dict) else Outcome("error", name, reason="remote", say=f"{self.base_url} answered {status}")
+
+ def run_intent(self, text: str, depth: int) -> Outcome:
+ status, body = http_json("POST", f"{self.base_url}/intent", {"text": text, "depth": depth})
+ return outcome_from_dict(body, "") if isinstance(body, dict) else Outcome("error", "", reason="remote", say=f"{self.base_url} answered {status}")
+
+
+def outcome_from_dict(d: dict[str, Any], fallback_tree: str = "") -> Outcome:
+ trace = None
+ if isinstance(d.get("trace"), dict):
+ from .trace import Step
+
+ trace = Trace(d["trace"].get("tree", fallback_tree))
+ trace.steps = [Step(**{k: v for k, v in s.items() if k in Step.__dataclass_fields__}) for s in d["trace"].get("steps", [])]
+ trace.gaps = list(d["trace"].get("gaps", []))
+ return Outcome(
+ status=d.get("status", "error"),
+ tree=d.get("tree") or fallback_tree,
+ say=d.get("say"),
+ output=d.get("output"),
+ reason=d.get("reason"),
+ question=d.get("question"),
+ snapshot=d.get("snapshot"),
+ trace=trace,
+ inputs=d.get("inputs") or {},
+ )
+
+
+class Service:
+ def __init__(self, registry: Registry, *, name: str = "pyrtan", transport: Transport | None = None, hub: str | None = None, env: dict[str, Any] | None = None, advertise: str | None = None) -> None:
+ self.registry = registry
+ self.name = name
+ self.hub = hub.rstrip("/") if hub else None
+ self.advertise = advertise
+ self.started = time.time()
+ self.runner = Runner(registry, transport or UrllibTransport(), resolver=RemoteResolver(self.hub) if self.hub else None, env=env or {})
+ self.gaps: list[dict[str, Any]] = []
+ self.runs = 0
+ self._lock = threading.Lock()
+
+ # ------------------------------------------------------------ handlers
+
+ def health(self) -> dict[str, Any]:
+ self.registry.reload()
+ return {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "uptime_s": int(time.time() - self.started)}
+
+ def capabilities(self) -> dict[str, Any]:
+ self.registry.reload()
+ return {"name": self.name, "trees": self.registry.capabilities()}
+
+ def intent(self, body: dict[str, Any]) -> dict[str, Any]:
+ text = body.get("text")
+ if not isinstance(text, str) or not text.strip():
+ return {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}
+ self.registry.reload()
+ return self._finish(self.runner.run_intent(text, int(body.get("depth", 0))), body)
+
+ def run(self, body: dict[str, Any]) -> dict[str, Any]:
+ tree = body.get("tree")
+ if not isinstance(tree, str):
+ return {"status": "error", "reason": "bad-request", "say": "Send {\"tree\": \"...\", \"inputs\": {...}}."}
+ self.registry.reload()
+ return self._finish(self.runner.run_tree(tree, body.get("inputs") or {}, int(body.get("depth", 0))), body)
+
+ def resume(self, body: dict[str, Any]) -> dict[str, Any]:
+ snapshot = body.get("snapshot")
+ if not isinstance(snapshot, dict) or "tree" not in snapshot:
+ return {"status": "error", "reason": "bad-request", "say": "Send {\"snapshot\": {...}, \"answer\": ...}."}
+ return self._finish(self.runner.resume(snapshot, body.get("answer")), body)
+
+ def _finish(self, outcome: Outcome, body: dict[str, Any]) -> dict[str, Any]:
+ with self._lock:
+ self.runs += 1
+ if outcome.trace is not None:
+ self.gaps.extend(outcome.trace.gaps)
+ return outcome.to_dict(with_trace=bool(body.get("trace", True)))
+
+ # ------------------------------------------------------------ hub
+
+ def register(self, my_url: str) -> bool:
+ if not self.hub:
+ return False
+ try:
+ status, _ = http_json("POST", f"{self.hub}/register", {"url": my_url, "name": self.name})
+ return status == 200
+ except Exception:
+ return False
+
+ # ------------------------------------------------------------ routing
+
+ def route(self, method: str, path: str, body: dict[str, Any] | None) -> tuple[int, Any]:
+ body = body or {}
+ if method == "GET" and path == "/health":
+ return 200, self.health()
+ if method == "GET" and path == "/capabilities":
+ return 200, self.capabilities()
+ if method == "GET" and path == "/gaps":
+ return 200, {"gaps": self.gaps}
+ if method == "GET" and path in ("/", "/index.html"):
+ return 200, PAGE
+ if method == "GET" and path == "/api":
+ return 200, {"pyrtan": "service", "name": self.name, "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps"]}
+ if method == "POST" and path == "/intent":
+ return 200, self.intent(body)
+ if method == "POST" and path == "/run":
+ return 200, self.run(body)
+ if method == "POST" and path == "/resume":
+ return 200, self.resume(body)
+ return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}
+
+
+def make_handler(route: Callable[[str, str, dict[str, Any] | None], tuple[int, Any]], quiet: bool = False):
+ class Handler(BaseHTTPRequestHandler):
+ server_version = "pyrtan/0.1"
+
+ def log_message(self, fmt: str, *args: Any) -> None:
+ if not quiet:
+ super().log_message(fmt, *args)
+
+ def _send(self, status: int, payload: Any) -> None:
+ if isinstance(payload, str):
+ data = payload.encode("utf-8")
+ ctype = "text/html; charset=utf-8"
+ else:
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ ctype = "application/json; charset=utf-8"
+ self.send_response(status)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_GET(self) -> None:
+ status, payload = route("GET", self.path.split("?", 1)[0], None)
+ self._send(status, payload)
+
+ def do_POST(self) -> None:
+ length = int(self.headers.get("Content-Length") or 0)
+ if length > MAX_BODY:
+ self._send(413, {"status": "error", "reason": "too-large"})
+ return
+ raw = self.rfile.read(length) if length else b""
+ try:
+ body = json.loads(raw.decode("utf-8")) if raw else {}
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ self._send(400, {"status": "error", "reason": "bad-json"})
+ return
+ if not isinstance(body, dict):
+ self._send(400, {"status": "error", "reason": "bad-json", "say": "The body must be a JSON object."})
+ return
+ status, payload = route("POST", self.path.split("?", 1)[0], body)
+ self._send(status, payload)
+
+ return Handler
+
+
+class Server:
+ """A running HTTP server around a route function, for a service or a hub."""
+
+ def __init__(self, route: Callable[[str, str, dict[str, Any] | None], tuple[int, Any]], host: str = "127.0.0.1", port: int = 0, quiet: bool = False) -> None:
+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet))
+ self.httpd.daemon_threads = True
+ self.thread: threading.Thread | None = None
+
+ @property
+ def port(self) -> int:
+ return self.httpd.server_address[1]
+
+ @property
+ def url(self) -> str:
+ host = self.httpd.server_address[0]
+ return f"http://{host}:{self.port}"
+
+ def start(self) -> "Server":
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
+ self.thread.start()
+ return self
+
+ def serve_forever(self) -> None:
+ self.httpd.serve_forever()
+
+ def stop(self) -> None:
+ self.httpd.shutdown()
+ self.httpd.server_close()
+
+
+def serve(paths: list[str | Path], host: str = "127.0.0.1", port: int = 8701, *, name: str = "pyrtan", hub: str | None = None, env: dict[str, Any] | None = None, advertise: str | None = None, quiet: bool = False) -> tuple[Service, Server]:
+ registry = Registry.from_paths(paths)
+ service = Service(registry, name=name, hub=hub, env=env, advertise=advertise)
+ server = Server(service.route, host, port, quiet)
+ if hub:
+ service.register(advertise or server.url)
+ return service, server
diff --git a/src/pyrtan/template.py b/src/pyrtan/template.py new file mode 100644 index 0000000..8288ac0 --- /dev/null +++ b/src/pyrtan/template.py
@@ -0,0 +1,170 @@
+"""``{{ path }}`` interpolation against the run's state.
+
+Templates are deliberately tiny. A placeholder is a dotted path into the
+state, optionally followed by one or more filters::
+
+ {{ inputs.city }}
+ {{ state.resp.json.results[0].name }}
+ {{ inputs.city | url }}
+ {{ state.count | int }}
+
+There is no logic in templates. Branching belongs to ``decide`` nodes, where
+it is visible in the trace; a template that could branch would hide it.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import urllib.parse
+from typing import Any
+
+PLACEHOLDER = re.compile(r"\{\{\s*([^{}]*?)\s*\}\}")
+PATH_TOKEN = re.compile(r"([A-Za-z_][A-Za-z0-9_\-]*)|\[(-?\d+)\]|\[\"([^\"]*)\"\]|\['([^']*)'\]")
+
+
+class TemplateError(ValueError):
+ pass
+
+
+class Missing:
+ """Sentinel for a path that does not resolve."""
+
+ def __repr__(self) -> str:
+ return "<missing>"
+
+
+MISSING = Missing()
+
+
+def resolve(path: str, scope: dict[str, Any]) -> Any:
+ """Resolve a dotted path like ``state.resp.json.items[0].title``.
+
+ Returns :data:`MISSING` when any step does not exist, rather than raising,
+ so that ``exists`` conditions can be expressed.
+ """
+ pos = 0
+ cur: Any = scope
+ path = path.strip()
+ if not path:
+ return MISSING
+ while pos < len(path):
+ if path[pos] == ".":
+ pos += 1
+ continue
+ m = PATH_TOKEN.match(path, pos)
+ if not m:
+ raise TemplateError(f"bad path {path!r} at offset {pos}")
+ pos = m.end()
+ name, index, key_dq, key_sq = m.groups()
+ if name is not None:
+ cur = _get_key(cur, name)
+ elif index is not None:
+ cur = _get_index(cur, int(index))
+ else:
+ cur = _get_key(cur, key_dq if key_dq is not None else key_sq)
+ if cur is MISSING:
+ return MISSING
+ return cur
+
+
+def _get_key(cur: Any, key: str) -> Any:
+ if isinstance(cur, dict):
+ return cur.get(key, MISSING)
+ if isinstance(cur, list) and key.isdigit():
+ return _get_index(cur, int(key))
+ return MISSING
+
+
+def _get_index(cur: Any, index: int) -> Any:
+ if isinstance(cur, (list, tuple, str)):
+ try:
+ return cur[index]
+ except IndexError:
+ return MISSING
+ return MISSING
+
+
+FILTERS = {
+ "url": lambda v: urllib.parse.quote(str(v), safe=""),
+ "json": lambda v: json.dumps(v, ensure_ascii=False),
+ "int": lambda v: int(float(v)),
+ "float": lambda v: float(v),
+ "str": lambda v: str(v),
+ "lower": lambda v: str(v).lower(),
+ "upper": lambda v: str(v).upper(),
+ "strip": lambda v: str(v).strip(),
+ "first": lambda v: v[0] if isinstance(v, (list, tuple, str)) and v else MISSING,
+ "last": lambda v: v[-1] if isinstance(v, (list, tuple, str)) and v else MISSING,
+ "length": lambda v: len(v) if hasattr(v, "__len__") else MISSING,
+ "title": lambda v: str(v).title(),
+}
+
+
+def render_value(expr: str, scope: dict[str, Any]) -> Any:
+ """Evaluate one placeholder expression (path plus filters) to a value."""
+ parts = [p.strip() for p in expr.split("|")]
+ value = resolve(parts[0], scope)
+ for name in parts[1:]:
+ if name not in FILTERS:
+ raise TemplateError(f"unknown filter {name!r} in {{{{ {expr} }}}}")
+ if value is MISSING:
+ continue
+ value = FILTERS[name](value)
+ return value
+
+
+def render(template: Any, scope: dict[str, Any], *, strict: bool = True) -> Any:
+ """Render a template.
+
+ - A string that is exactly one placeholder returns the resolved value with
+ its type intact, so ``"{{ state.ids }}"`` yields a list, not its repr.
+ - A string with surrounding text interpolates placeholders as text.
+ - Lists and dicts are rendered recursively.
+ - Other values pass through.
+
+ With ``strict`` (the default) a placeholder that resolves to nothing raises
+ :class:`TemplateError`; a tree should decide on absence explicitly rather
+ than paste an empty string into a URL.
+ """
+ if isinstance(template, str):
+ whole = PLACEHOLDER.fullmatch(template.strip())
+ if whole and template.strip() == template:
+ value = render_value(whole.group(1), scope)
+ if value is MISSING:
+ if strict:
+ raise TemplateError(f"{template} resolves to nothing")
+ return None
+ return value
+
+ def sub(m: re.Match[str]) -> str:
+ value = render_value(m.group(1), scope)
+ if value is MISSING:
+ if strict:
+ raise TemplateError(f"{m.group(0)} resolves to nothing")
+ return ""
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False)
+ return str(value)
+
+ return PLACEHOLDER.sub(sub, template)
+ if isinstance(template, list):
+ return [render(item, scope, strict=strict) for item in template]
+ if isinstance(template, dict):
+ return {k: render(v, scope, strict=strict) for k, v in template.items()}
+ return template
+
+
+def placeholders(template: Any) -> list[str]:
+ """Every placeholder path used anywhere in a template, filters stripped."""
+ found: list[str] = []
+ if isinstance(template, str):
+ for m in PLACEHOLDER.finditer(template):
+ found.append(m.group(1).split("|")[0].strip())
+ elif isinstance(template, list):
+ for item in template:
+ found.extend(placeholders(item))
+ elif isinstance(template, dict):
+ for v in template.values():
+ found.extend(placeholders(v))
+ return found
diff --git a/src/pyrtan/trace.py b/src/pyrtan/trace.py new file mode 100644 index 0000000..14e980f --- /dev/null +++ b/src/pyrtan/trace.py
@@ -0,0 +1,74 @@
+"""What a run did, step by step.
+
+A trace is the product that distinguishes a decision tree from a model: every
+observation is recorded, every branch names the condition that chose it, and
+the whole thing is plain data a person or another service can read.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import dataclass, field, asdict
+from typing import Any
+
+
+@dataclass
+class Step:
+ node: str
+ kind: str
+ summary: str
+ observed: Any = None
+ chosen: str | None = None
+ next: str | None = None
+ elapsed_ms: int = 0
+ depth: int = 0
+
+ def to_dict(self) -> dict[str, Any]:
+ d = asdict(self)
+ if d["observed"] is None:
+ del d["observed"]
+ if d["chosen"] is None:
+ del d["chosen"]
+ return d
+
+
+@dataclass
+class Trace:
+ tree: str
+ steps: list[Step] = field(default_factory=list)
+ started: float = field(default_factory=time.time)
+ gaps: list[dict[str, Any]] = field(default_factory=list)
+
+ def add(self, step: Step) -> Step:
+ self.steps.append(step)
+ return step
+
+ def gap(self, node: str, on: str, observed: Any) -> None:
+ """Record a decision whose observed value matched no branch. Gaps are
+ how a tree grows: each one is a branch an author has not written yet."""
+ self.gaps.append({"tree": self.tree, "node": node, "on": on, "observed": _short(observed)})
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"tree": self.tree, "started": self.started, "steps": [s.to_dict() for s in self.steps], "gaps": self.gaps}
+
+ def render(self) -> str:
+ lines = [f"trace: {self.tree}"]
+ for s in self.steps:
+ pad = " " * (s.depth + 1)
+ arrow = f" -> {s.next}" if s.next else ""
+ chosen = f" [{s.chosen}]" if s.chosen else ""
+ lines.append(f"{pad}{s.node} ({s.kind}){chosen}: {s.summary}{arrow}")
+ for g in self.gaps:
+ lines.append(f" gap: {g['node']} saw {g['on']} = {json.dumps(g['observed'], ensure_ascii=False)} and had no branch for it")
+ return "\n".join(lines)
+
+
+def _short(value: Any, limit: int = 200) -> Any:
+ if isinstance(value, str) and len(value) > limit:
+ return value[:limit] + "…"
+ if isinstance(value, (dict, list)):
+ text = json.dumps(value, ensure_ascii=False)
+ if len(text) > limit:
+ return text[:limit] + "…"
+

The diff is cut here; the rest is in the clone.