@projectpac/flow-router
v0.4.1
Published
Part of PAC: @projectpac/flow-router.
Readme
@projectpac/flow-router
A principal types one thing, and something has to decide which flow that is.
The only flow here that talks to no peer. It reads what the network is offering -- the skills board, the query board, and what this principal holds -- asks its own agent which of those the text is asking for, and writes the answer down.
It decides, and then starts what it decided. It still does not reach into another flow: a flow that did would be starting work with itself as the caller, and none of the caller-derived accounting PAC is built on would mean anything. What it does instead is call the route the chosen intent declared, through the control api -- which runs as the target flow's own handler, so the work is that flow's, exactly as if the principal had called it. Before this, a decision sat unstarted unless an interface had been written knowing the target by name, and the two nothing had been written for were decided about and dropped.
What it can route to is not a list in this file, and neither is how to start
it. This is a flow service claiming ctx.router, and the choices are the
intents the flows on this node declare into it. A routable flow declares from
an optional child scope -- ctx.inject(["router"], (scoped) =>
scoped.router.intent({...})) -- so a node with no router still runs the flow,
and one that gains a router later hears from it the moment the service comes
up. Each intent also declares its own entry -- which of that flow's routes
starts it, whether it needs a peer or an offer first, and what that flow's own
body calls the pieces. So a flow somebody adds afterwards is routable and
startable the moment it is installed, and one that is disabled stops being
offered -- the registration is an effect of the flow's own scope -- without this
flow or any interface knowing anything about either. The declaring plugin is
derived, never named, so nothing can declare for another flow; a name is one
flow's, and a node gets one fallback, with a second of either refused where it
is declared.
An intent that needs a peer starts one per peer that speaks the flow.
Every node that runs a flow advertises it under one kind, so who speaks it is
one narrow question of discovery, and an empty answer is an answer: starting
with whoever this node happens to know would put the guess back, a message
nobody can answer. The decision named a kind of work, not a counterparty. That
is right for work that is an offer anyone may take up; a flow where starting
stakes something the principal owns wants its own guard against agreeing twice.
GET /available is the same question asked for an interface -- what the peers
say they speak, and whether this node has it -- which is what a principal wants
to know before installing something.
The board is the part worth being careful about. Its rows come from a directory: not this node, not a peer this node chose, so a name or a description is text of unknown origin. It travels as the run's context, which the loop delimits and labels as data, and anything in it that could close a delimiter is neutralized first -- otherwise a hostile row would not so much inject a prompt as break every routing run this node ever does. The model's answer is then re-validated against the live board: a skill that is not on it cannot be picked.
| | |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| service | ctx.router: intent(declaration), what a routable flow declares into |
| routes | POST /prompts, GET /prompts, GET /board, GET /available, POST /prompts/:prompt/dispatched, POST /prompts/:prompt/failed |
| record | refs: what a decision started, several when it fanned out over peers; kept through ctx.records |
| payloads | none -- nothing crosses the wire |
| background | the sweep, every 60s on an interval of its own, for a decision nothing ever dispatched |
| needs | any source, to say what this principal holds; no operations, no tools |
| intents | none: it is the thing that routes, not a thing routed to |
Running it
pnpm check # includes tests/router.spec.ts: decisions over a live board
just demo # headless: two nodes, everything routable installed, one scripted decision dispatched
just dev # the lived-in network: panes, pages, the box -- then say one thing:
just prompt "find the spotify artists we both listen to"
just prompt "plan me a weekend in Lisbon"
just prompt "What does my Spotify music say about my taste? Be specific."
just a2-prompt "Name a playlist for late night driving, mostly synths"
just prompt "I want to trade my 1 log for 1 copper"The router routes to what the node speaks, so its lane installs everything
routable, after the record store every flow here keeps its records through:
the skills flow with the sample library, the template's echo, joint
search once the box pane answers, and the objects flow once the DON pane's
object daemons do -- the one intent here that needs a peer rather than an
offer, which is what makes the fan-out visible. With no MODEL the scripted executor
always picks the fallback echo; real routing wants
MODEL=pi MODEL_ARG="-p --model ..." just dev. devtools/README.md
is the manual.
The sibling layout
Cross-repo dependencies are link: paths into checkouts beside this one --
nothing of PAC's is fetched from a registry. Clone the repos this one links
(the link: values in the package manifests) into the same parent directory,
run pnpm install, and everything resolves from source. The flow's whole-node
suite lives in tests/, standing up real nodes from the sibling pac-node.
The design repo
(pac) is the front door: what PAC is, and how the repos fit together.
