urirun-reasoner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: urirun-reasoner
3
+ Version: 0.1.0
4
+ Summary: Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback.
5
+ Author: Tom Sapletta
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # urirun-reasoner
11
+
12
+ Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
13
+
14
+ A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
15
+ "test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
16
+ "produce a document artifact and verify it exists" and resolves it through the cheapest
17
+ feasible URI path.
18
+
19
+ ## The resolution ladder (kernel decides, cheapest/safest first)
20
+
21
+ 1. **direct** — a capability is already served on the node (headless)
22
+ 2. **host_fallback** — a headless capability runs host-side + sync (no node change)
23
+ 3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
24
+ 4. **gui** — a GUI capability, only as a last resort
25
+ 5. **blocked** — nothing feasible → honest, with what was tried
26
+
27
+ GUI is never chosen when a headless path exists; connectors are never installed blindly.
28
+
29
+ ## Layers
30
+
31
+ | module | role |
32
+ |---|---|
33
+ | `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
34
+ | `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
35
+ | `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
36
+ | `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
37
+
38
+ ## Use
39
+
40
+ ```python
41
+ from urirun_reasoner import Context, resolve_prompt
42
+ ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
43
+ installable={"sheet"}, can_manage=True)
44
+ plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
45
+ # → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
46
+ ```
47
+
48
+ Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
49
+ (so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
@@ -0,0 +1,40 @@
1
+ # urirun-reasoner
2
+
3
+ Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
4
+
5
+ A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
6
+ "test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
7
+ "produce a document artifact and verify it exists" and resolves it through the cheapest
8
+ feasible URI path.
9
+
10
+ ## The resolution ladder (kernel decides, cheapest/safest first)
11
+
12
+ 1. **direct** — a capability is already served on the node (headless)
13
+ 2. **host_fallback** — a headless capability runs host-side + sync (no node change)
14
+ 3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
15
+ 4. **gui** — a GUI capability, only as a last resort
16
+ 5. **blocked** — nothing feasible → honest, with what was tried
17
+
18
+ GUI is never chosen when a headless path exists; connectors are never installed blindly.
19
+
20
+ ## Layers
21
+
22
+ | module | role |
23
+ |---|---|
24
+ | `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
25
+ | `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
26
+ | `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
27
+ | `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ from urirun_reasoner import Context, resolve_prompt
33
+ ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
34
+ installable={"sheet"}, can_manage=True)
35
+ plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
36
+ # → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
37
+ ```
38
+
39
+ Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
40
+ (so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "urirun-reasoner"
7
+ version = "0.1.0"
8
+ description = "Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Tom Sapletta" }]
13
+ dependencies = []
14
+
15
+ [project.scripts]
16
+ urirun-reasoner = "urirun_reasoner.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ include = ["urirun_reasoner*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,119 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """The reasoner contract: model the goal not the app, and walk the ladder cheapest-first.
3
+ GUI is the LAST resort; a missing editor never ends the process."""
4
+ from urirun_reasoner import Context, intent, needs, planner, resolve, resolve_prompt
5
+
6
+
7
+ def test_intent_maps_office_words_to_document_need():
8
+ assert intent.classify("napisz artykuł w edytorze tekstu").id == "office.document.create"
9
+ assert intent.classify("przetestuj biuro na lenovo").id == "office.document.create"
10
+ assert intent.classify("stwórz arkusz z danymi").id == "office.spreadsheet.create"
11
+ assert intent.classify("zupełnie coś innego") is None
12
+
13
+
14
+ def test_direct_when_capability_already_served():
15
+ ctx = Context(node="laptop", node_served=["sheet://laptop/workbook/command/write"])
16
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
17
+ assert p["resolved"] and p["strategy"] == "direct" and p["capability"] == "sheet.write"
18
+ assert p["steps"][0]["uri"] == "sheet://laptop/workbook/command/write"
19
+
20
+
21
+ def test_host_fallback_before_install_when_node_lacks_it():
22
+ # node serves nothing useful; host can write files → host_fallback beats installing
23
+ ctx = Context(node="laptop", node_served=["kvm://laptop/screen/query/capture"],
24
+ host_served=["fs://host/file/command/write"],
25
+ installable={"sheet"}, can_manage=True)
26
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
27
+ assert p["strategy"] == "host_fallback" and p["capability"] == "markdown.write"
28
+ assert p["steps"][0]["uri"] == "fs://host/file/command/write"
29
+
30
+
31
+ def test_install_when_no_direct_and_no_host_fallback():
32
+ # nothing served anywhere, but a connector is installable and the node can manage
33
+ ctx = Context(node="laptop", node_served=[], host_served=[],
34
+ installable={"sheet"}, can_manage=True)
35
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
36
+ assert p["strategy"] == "install_then_run" and p["connector"] == "sheet"
37
+ uris = [s["uri"] for s in p["steps"]]
38
+ assert uris[0] == "node://laptop/connector/command/install"
39
+ assert "node://laptop/runtime/command/restart" in uris # drop stale workers
40
+ assert uris[-1].startswith("sheet://laptop/") # then actually use it
41
+
42
+
43
+ def test_gui_is_last_resort_only_when_allowed_and_nothing_else():
44
+ ctx = Context(node="laptop", node_served=["browser://laptop/page/command/navigate"],
45
+ host_served=[], installable=set(), can_manage=False, allow_gui=True)
46
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
47
+ assert p["strategy"] == "gui" and p["capability"] == "browser.document_edit"
48
+
49
+
50
+ def test_gui_not_chosen_when_headless_exists():
51
+ # both a served browser (gui) AND a served sheet (headless) → headless wins
52
+ ctx = Context(node="laptop",
53
+ node_served=["browser://laptop/page/command/navigate",
54
+ "sheet://laptop/workbook/command/write"],
55
+ allow_gui=True)
56
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
57
+ assert p["strategy"] == "direct" and p["capability"] == "sheet.write"
58
+
59
+
60
+ def test_blocked_is_honest_with_what_was_tried():
61
+ ctx = Context(node="laptop", node_served=[], host_served=[], installable=set(),
62
+ can_manage=False, allow_gui=False)
63
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
64
+ assert p["resolved"] is False and set(p["tried"]) >= {"direct", "host_fallback", "install", "gui"}
65
+
66
+
67
+ def test_plan_carries_postcondition():
68
+ ctx = Context(node="laptop", node_served=["sheet://laptop/workbook/command/write"])
69
+ p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
70
+ assert p["postcondition"] and p["postcondition"]["check"] == "exists"
71
+
72
+
73
+ def test_resolve_prompt_office_on_lenovo_reframes_to_headless():
74
+ # THE flagship case: "test office on lenovo" with no GUI → headless host fallback
75
+ ctx = Context(node="lenovo", node_served=["kvm://lenovo/screen/query/capture"],
76
+ host_served=["fs://host/file/command/write"], installable={"sheet"},
77
+ can_manage=True, allow_gui=True)
78
+ p = resolve_prompt("przetestuj biuro na lenovo", ctx)
79
+ assert p["resolved"] and p["strategy"] == "host_fallback"
80
+ assert p["need"] == "office.document.create" and not p["strategy"] == "gui"
81
+
82
+
83
+ # --- gates: risky actions are never ungated -------------------------------------
84
+ def test_gate_classifies_risky_verbs():
85
+ from urirun_reasoner import gates
86
+ assert gates.gate_for("linkedin://user/post/command/publish")
87
+ assert gates.gate_for("fiverr://user/order/command/pay")
88
+ assert gates.gate_for("email://user/message/command/send")
89
+ assert gates.gate_for("fs://host/file/command/write") is None # autonomous
90
+ assert gates.gate_for("sheet://host/workbook/command/write") is None
91
+
92
+
93
+ def test_gate_inserts_approval_before_publish():
94
+ from urirun_reasoner import gates
95
+ steps = [{"uri": "llm://host/social/command/write-linkedin-post"},
96
+ {"uri": "linkedin://user/post/command/publish"}]
97
+ out = gates.apply(steps)
98
+ assert out[0]["uri"].endswith("social/command/write-linkedin-post") # autonomous kept
99
+ assert out[1]["uri"].startswith("approval://human/") # gate inserted
100
+ assert out[2]["gate"] == "required"
101
+ assert gates.has_ungated_risk(out) == [] # audit clean
102
+
103
+
104
+ def test_audit_catches_bare_payment():
105
+ from urirun_reasoner import gates
106
+ risky = gates.has_ungated_risk([{"uri": "fiverr://user/order/command/pay"}])
107
+ assert risky == ["fiverr://user/order/command/pay"]
108
+
109
+
110
+ def test_generator_scaffolds_a_valid_connector(tmp_path):
111
+ from urirun_reasoner import generator
112
+ spec = {"id": "demo", "scheme": "demo", "summary": "x",
113
+ "handlers": [{"route": "thing/query/list", "params": "", "body": 'return _ok(action="x")'}]}
114
+ r = generator.generate(spec, tmp_path)
115
+ assert r["ok"] and r["routes"] == ["thing/query/list"]
116
+ core = (tmp_path / "urirun-connector-demo" / "urirun_connector_demo" / "core.py").read_text()
117
+ assert 'scheme="demo"' in core and "def thing_query_list" in core
118
+ assert (tmp_path / "urirun-connector-demo" / "pyproject.toml").is_file()
119
+ assert (tmp_path / "urirun-connector-demo" / "tests" / "test_demo.py").is_file()
@@ -0,0 +1,26 @@
1
+ # Author: Tom Sapletta · https://tom.sapletta.com
2
+ # Part of the ifURI solution.
3
+ """urirun-reasoner — intent → need → capability → URI plan, headless-first.
4
+
5
+ Model the GOAL, not the app. A missing GUI editor is one blocked method in a graph, not
6
+ the end of the road: the reasoner reframes "test office" into "produce a document
7
+ artifact" and resolves it through the cheapest feasible URI path (served route → host
8
+ fallback → install connector → GUI last). Deterministic core; LLM only where rules can't.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from . import capabilities, intent, needs, planner
13
+ from .needs import NeedSpec
14
+ from .planner import Context, resolve
15
+
16
+ __all__ = ["capabilities", "intent", "needs", "planner", "NeedSpec", "Context", "resolve"]
17
+
18
+
19
+ def resolve_prompt(prompt: str, ctx: "Context", *, llm=None) -> dict:
20
+ """One call: classify the prompt to a need, then resolve it to a URI plan."""
21
+ need = intent.classify(prompt, llm=llm)
22
+ if not need:
23
+ return {"resolved": False, "reason": f"could not classify intent: {prompt!r}"}
24
+ plan = resolve(need, ctx)
25
+ plan["goal"] = need.goal
26
+ return plan
@@ -0,0 +1,83 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """CapabilityMap — the missing layer between a NEED and concrete URIs/connectors.
3
+
4
+ A capability (e.g. ``document.create``) maps to the routes that provide it and the
5
+ connectors that install it. This is what turns "I need to author a document" into
6
+ "call sheet://… , or install urirun-connector-sheet". Route templates use ``{node}``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Capability:
16
+ id: str
17
+ routes: tuple[str, ...] # URI templates ({node} placeholder)
18
+ connectors: tuple[str, ...] = () # connector ids that provide this capability
19
+ gui: bool = False
20
+ host_ok: bool = True # can this run host-side as a fallback?
21
+
22
+
23
+ MAP: dict[str, Capability] = {
24
+ "document.create": Capability("document.create",
25
+ ("document://{node}/document/command/create", "odt://{node}/document/command/create"),
26
+ ("document", "office"), host_ok=True),
27
+ "sheet.write": Capability("sheet.write",
28
+ ("sheet://{node}/workbook/command/write", "sheet://{node}/rows/command/write",
29
+ "xlsx://{node}/workbook/command/write"),
30
+ ("sheet",), host_ok=True),
31
+ "invoice.render": Capability("invoice.render",
32
+ ("invoice://{node}/invoice/command/render", "invoice://{node}/document/command/build"),
33
+ ("invoice",), host_ok=True),
34
+ "markdown.write": Capability("markdown.write",
35
+ ("fs://{node}/file/command/write",), ("fs",), host_ok=True),
36
+ "html.write": Capability("html.write",
37
+ ("fs://{node}/file/command/write",), ("fs",), host_ok=True),
38
+ "browser.document_edit": Capability("browser.document_edit",
39
+ ("browser://{node}/page/command/navigate", "cdp://{node}/page/command/navigate"),
40
+ ("browser", "cdp"), gui=True, host_ok=False),
41
+ "screen.capture": Capability("screen.capture",
42
+ ("screen://{node}/screen/query/capture", "kvm://{node}/screen/query/capture",
43
+ "view://{node}/screenshot/query/capture"),
44
+ ("kvm",), host_ok=False),
45
+ }
46
+
47
+
48
+ def get(cap_id: str) -> Capability | None:
49
+ return MAP.get(cap_id)
50
+
51
+
52
+ def _served(route_tmpl: str, node: str, served: list[str]) -> str | None:
53
+ """Return the concrete served route matching this template for ``node``, else None."""
54
+ want = route_tmpl.replace("{node}", node)
55
+ tail = want.split("://", 1)[-1]
56
+ for r in served:
57
+ if r == want or fnmatch.fnmatch(r.split("://", 1)[-1], tail) or \
58
+ fnmatch.fnmatch(r.split("://", 1)[-1], route_tmpl.split("://", 1)[-1].replace("{node}", "*")):
59
+ return r
60
+ return None
61
+
62
+
63
+ def served_route(cap_id: str, node: str, served: list[str]) -> str | None:
64
+ """Is this capability already served on the node? Return the concrete route or None."""
65
+ cap = MAP.get(cap_id)
66
+ if not cap:
67
+ return None
68
+ for tmpl in cap.routes:
69
+ hit = _served(tmpl, node, served)
70
+ if hit:
71
+ return hit
72
+ return None
73
+
74
+
75
+ def install_route(cap_id: str, node: str) -> str:
76
+ """The concrete route to call once the capability's connector is installed."""
77
+ cap = MAP.get(cap_id)
78
+ return cap.routes[0].replace("{node}", node) if cap else ""
79
+
80
+
81
+ def providers(cap_id: str) -> tuple[str, ...]:
82
+ cap = MAP.get(cap_id)
83
+ return cap.connectors if cap else ()
@@ -0,0 +1,25 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """urirun-reasoner CLI: resolve a prompt to a headless-first URI plan."""
3
+ from __future__ import annotations
4
+ import argparse, json
5
+ from . import Context, resolve_prompt
6
+
7
+
8
+ def main(argv=None) -> int:
9
+ ap = argparse.ArgumentParser(prog="urirun-reasoner")
10
+ ap.add_argument("prompt")
11
+ ap.add_argument("--node", default="host")
12
+ ap.add_argument("--node-served", nargs="*", default=[])
13
+ ap.add_argument("--host-served", nargs="*", default=[])
14
+ ap.add_argument("--installable", nargs="*", default=[])
15
+ ap.add_argument("--can-manage", action="store_true")
16
+ ap.add_argument("--allow-gui", action="store_true")
17
+ a = ap.parse_args(argv)
18
+ ctx = Context(node=a.node, node_served=a.node_served, host_served=a.host_served,
19
+ installable=set(a.installable), can_manage=a.can_manage, allow_gui=a.allow_gui)
20
+ print(json.dumps(resolve_prompt(a.prompt, ctx), indent=1, default=str))
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """Gates — the hard rules on what the system may NEVER do without a human.
3
+
4
+ A plan step is classified by its URI: publishing, paying, messaging a vendor, ordering,
5
+ or sending private files are ALWAYS human-gated; searching, drafting, packaging,
6
+ comparing, generating a document are autonomous. The planner inserts an
7
+ ``approval://human/...`` gate before any gated step, or downgrades it to draft mode.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ # (matcher substring, gate reason) — a step whose URI contains the substring is gated
12
+ _GATED: list[tuple[str, str]] = [
13
+ ("/command/publish", "public publication requires human approval"),
14
+ ("/command/pay", "payment always requires human approval"),
15
+ ("/command/schedule", "scheduling a public post requires human approval"),
16
+ ("/message/command/send", "sending a message to a vendor requires human approval"),
17
+ ("/email/command/send", "sending email requires human approval"),
18
+ ("/order/command", "placing/ordering a service requires human approval"),
19
+ ("/command/confirm", "confirmation of a paid/committed action requires a human"),
20
+ ]
21
+
22
+ # autonomous-safe verbs (never gated) — for documentation / assertion
23
+ AUTONOMOUS = ("query/", "command/write", "command/draft", "command/package",
24
+ "command/render", "command/create", "command/summarize", "command/extract",
25
+ "command/compare", "command/search", "command/build")
26
+
27
+
28
+ def gate_for(uri: str) -> str | None:
29
+ """Return the human-gate reason for a URI, or None if it is autonomous-safe."""
30
+ u = str(uri)
31
+ for needle, reason in _GATED:
32
+ if needle in u:
33
+ return reason
34
+ return None
35
+
36
+
37
+ def apply(steps: list[dict], *, node_for_approval: str = "human") -> list[dict]:
38
+ """Insert an approval:// gate before each gated step; leave autonomous steps as-is.
39
+
40
+ Returns a new step list. A gated step also gets ``gate: "required"`` so an executor
41
+ refuses to run it until the preceding approval is granted (draft-then-approve)."""
42
+ out: list[dict] = []
43
+ for step in steps:
44
+ reason = gate_for(step.get("uri", ""))
45
+ if reason:
46
+ out.append({"uri": f"approval://{node_for_approval}/action/command/review",
47
+ "for": "gate", "reason": reason, "reviews": step.get("uri")})
48
+ out.append({**step, "gate": "required"})
49
+ else:
50
+ out.append(step)
51
+ return out
52
+
53
+
54
+ def has_ungated_risk(steps: list[dict]) -> list[str]:
55
+ """Audit: return URIs of any gated step NOT immediately preceded by an approval.
56
+ A safety net — the plan must never carry a bare publish/pay/send."""
57
+ risky: list[str] = []
58
+ prev_is_gate = False
59
+ for step in steps:
60
+ uri = step.get("uri", "")
61
+ if gate_for(uri) and not prev_is_gate:
62
+ risky.append(uri)
63
+ prev_is_gate = uri.startswith("approval://")
64
+ return risky
@@ -0,0 +1,57 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """Generation policy — the rules under which the system may create a NEW connector itself.
3
+
4
+ Autonomy needs guardrails, not permission prompts. A connector spec is admissible only if
5
+ it introduces a NEW scheme (never silently overwrites a served one), every handler routes
6
+ through the urirun envelope, any destructive/network verb (delete/pay/publish/send) is
7
+ marked gated, no secret is baked inline, and the id/scheme are clean slugs. A generated
8
+ connector must pass its own smoke test before it is installed or published. These checks
9
+ are what let the reasoner call ``connector://.../generate`` without asking a human first.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any
15
+
16
+ _SLUG = re.compile(r"^[a-z][a-z0-9-]{1,39}$")
17
+ _GATED_VERBS = ("delete", "pay", "publish", "send", "order", "remove", "drop", "wipe", "purge")
18
+ _SECRET_HINT = re.compile(r"(password|api[_-]?key|secret|token)\s*=\s*['\"][^'\"]+['\"]", re.I)
19
+
20
+
21
+ def check(spec: dict, *, served_schemes: set | None = None) -> dict[str, Any]:
22
+ """Validate a connector spec against the generation policy. Returns
23
+ {ok, violations, gated_routes}. ``served_schemes`` are schemes already served (must not
24
+ be overwritten)."""
25
+ violations: list[str] = []
26
+ cid = str(spec.get("id", ""))
27
+ scheme = str(spec.get("scheme", cid))
28
+ if not _SLUG.match(cid):
29
+ violations.append(f"id {cid!r} is not a clean slug [a-z][a-z0-9-]{{1,39}}")
30
+ if not _SLUG.match(scheme):
31
+ violations.append(f"scheme {scheme!r} is not a clean slug")
32
+ if served_schemes and scheme in served_schemes:
33
+ violations.append(f"scheme '{scheme}://' is already served — refuse to overwrite (bump/rename instead)")
34
+
35
+ handlers = spec.get("handlers") or []
36
+ if not handlers:
37
+ violations.append("spec has no handlers")
38
+ gated_routes: list[str] = []
39
+ for h in handlers:
40
+ route = str(h.get("route", ""))
41
+ if "/" not in route:
42
+ violations.append(f"handler route {route!r} must look like 'noun/verb/name'")
43
+ body = str(h.get("body", ""))
44
+ if _SECRET_HINT.search(body):
45
+ violations.append(f"handler {route!r} appears to hardcode a secret — use secret:// references")
46
+ # a destructive/network verb must be declared gated so an executor requires approval
47
+ if any(v in route.lower() for v in _GATED_VERBS):
48
+ if not h.get("gated"):
49
+ violations.append(f"handler {route!r} uses a destructive/network verb but is not marked gated")
50
+ gated_routes.append(route)
51
+
52
+ return {"ok": not violations, "violations": violations, "gated_routes": gated_routes,
53
+ "id": cid, "scheme": scheme}
54
+
55
+
56
+ def admissible(spec: dict, *, served_schemes: set | None = None) -> bool:
57
+ return check(spec, served_schemes=served_schemes)["ok"]
@@ -0,0 +1,149 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """Connector generator — the autonomy that CLOSES the loop instead of waiting for a human.
3
+
4
+ When the reasoner reports a capability blocked because a scheme has no connector, this
5
+ generator scaffolds a complete, installable urirun connector from a spec (id, scheme,
6
+ handlers). Handler bodies come from the spec (deterministic) or an LLM (for novel logic);
7
+ the structure — pyproject, entry point, envelope, manifest, __init__, a smoke test — is
8
+ templated and always valid. The result is a package a node can pip-install.
9
+
10
+ spec = {"id": "document", "scheme": "document", "summary": "...",
11
+ "handlers": [{"route": "document/command/create", "label": "...",
12
+ "params": "path: str = '', text: str = ''", "body": "..."}]}
13
+ generate(spec, dest) -> writes urirun-connector-<id>/…
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ _PYPROJECT = '''[build-system]
21
+ requires = ["setuptools>=61"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [project]
25
+ name = "urirun-connector-{id}"
26
+ version = "0.1.0"
27
+ description = "{summary}"
28
+ readme = "README.md"
29
+ requires-python = ">=3.10"
30
+ license = {{ text = "Apache-2.0" }}
31
+ authors = [{{ name = "Tom Sapletta" }}]
32
+ dependencies = ["urirun>=0.4.14"{extra_deps}]
33
+
34
+ [project.entry-points."urirun.bindings"]
35
+ {id} = "urirun_connector_{mod}.core:urirun_bindings"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["urirun_connector_{mod}*"]
39
+ '''
40
+
41
+ _CORE_HEADER = '''# Author: Tom Sapletta · Part of the ifURI solution — GENERATED by urirun-reasoner.
42
+ """urirun-connector-{id} — {summary}
43
+
44
+ Generated autonomously to satisfy a capability the reasoner found blocked (no connector
45
+ served the {scheme}:// scheme). Built to URI_NATIVE_CONNECTOR_CHECKLIST: lazy imports,
46
+ handlers never raise (urirun envelope), queries in-process.
47
+ """
48
+ from __future__ import annotations
49
+
50
+ from typing import Any
51
+
52
+ import urirun
53
+
54
+ CONNECTOR_ID = "{id}"
55
+ conn = urirun.connector(CONNECTOR_ID, scheme="{scheme}")
56
+
57
+
58
+ def _ok(**kw: Any) -> dict[str, Any]:
59
+ return urirun.ok(connector=CONNECTOR_ID, **kw)
60
+
61
+
62
+ def _fail(msg: str, action: str, **extra: Any) -> dict[str, Any]:
63
+ return urirun.fail(msg, connector=CONNECTOR_ID, action=action, **extra)
64
+
65
+ '''
66
+
67
+ _HANDLER = '''
68
+ @conn.handler("{route}", isolated={isolated},
69
+ meta={{"label": "{label}"}})
70
+ def {fn}({params}) -> dict[str, Any]:
71
+ """{doc}"""
72
+ try:
73
+ {body}
74
+ except Exception as exc: # noqa: BLE001
75
+ return _fail(str(exc), "{action}")
76
+
77
+ '''
78
+
79
+ _CORE_FOOTER = '''
80
+ def urirun_bindings() -> dict[str, Any]:
81
+ return conn.bindings()
82
+
83
+
84
+ def connector_manifest() -> dict[str, Any]:
85
+ return urirun.load_manifest(__package__) or {"id": CONNECTOR_ID}
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ return conn.cli(argv, manifest_prose=urirun.load_manifest(__package__))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
94
+ '''
95
+
96
+
97
+ def _fn_name(route: str) -> str:
98
+ return route.replace("/", "_").replace("-", "_")
99
+
100
+
101
+ def _core(spec: dict) -> str:
102
+ parts = [_CORE_HEADER.format(id=spec["id"], scheme=spec.get("scheme", spec["id"]),
103
+ summary=spec.get("summary", ""))]
104
+ for h in spec.get("handlers", []):
105
+ body = "\n".join(" " + ln for ln in (h.get("body") or 'return _ok(action="todo")').splitlines())
106
+ parts.append(_HANDLER.format(
107
+ route=h["route"], isolated=str(bool(h.get("isolated", "/command/" in h["route"]))),
108
+ label=h.get("label", h["route"]), fn=_fn_name(h["route"]),
109
+ params=h.get("params", ""), doc=h.get("doc", h.get("label", h["route"])),
110
+ body=body, action=h.get("action", spec["id"] + "-" + _fn_name(h["route"]))))
111
+ parts.append(_CORE_FOOTER)
112
+ return "".join(parts)
113
+
114
+
115
+ def _smoke_test(spec: dict) -> str:
116
+ mod = spec["id"].replace("-", "_")
117
+ lines = ["# GENERATED smoke test — every handler returns a well-formed urirun envelope.",
118
+ f"from urirun_connector_{mod} import core\n\n",
119
+ "def test_bindings_expose_all_routes():",
120
+ f" text = str(core.urirun_bindings())"]
121
+ for h in spec.get("handlers", []):
122
+ lines.append(f' assert {h["route"]!r} in text')
123
+ return "\n".join(lines) + "\n"
124
+
125
+
126
+ def generate(spec: dict, dest: str | Path) -> dict[str, Any]:
127
+ """Write a complete connector package under ``dest/urirun-connector-<id>/``."""
128
+ cid = spec["id"]
129
+ mod = cid.replace("-", "_")
130
+ root = Path(dest) / f"urirun-connector-{cid}"
131
+ pkg = root / f"urirun_connector_{mod}"
132
+ (pkg / "").mkdir(parents=True, exist_ok=True)
133
+ (root / "tests").mkdir(parents=True, exist_ok=True)
134
+
135
+ extra = "".join(f', "{d}"' for d in spec.get("deps", []))
136
+ (root / "pyproject.toml").write_text(
137
+ _PYPROJECT.format(id=cid, mod=mod, summary=spec.get("summary", ""), extra_deps=extra),
138
+ encoding="utf-8")
139
+ (pkg / "core.py").write_text(_core(spec), encoding="utf-8")
140
+ (pkg / "__init__.py").write_text(
141
+ f"from .core import CONNECTOR_ID, connector_manifest, main, urirun_bindings\n"
142
+ f'__all__ = ["CONNECTOR_ID", "connector_manifest", "main", "urirun_bindings"]\n',
143
+ encoding="utf-8")
144
+ (root / "tests" / f"test_{mod}.py").write_text(_smoke_test(spec), encoding="utf-8")
145
+ (root / "README.md").write_text(
146
+ f"# urirun-connector-{cid}\n\n{spec.get('summary','')}\n\n"
147
+ f"Generated by urirun-reasoner to serve the `{spec.get('scheme', cid)}://` scheme.\n"
148
+ f"Part of the ifURI solution · Apache-2.0\n", encoding="utf-8")
149
+ return {"ok": True, "path": str(root), "routes": [h["route"] for h in spec.get("handlers", [])]}
@@ -0,0 +1,40 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """Intent classification — prompt → NeedSpec. Rules first, LLM only as a fallback.
3
+
4
+ Most needs are recognizable by keyword (deterministic, no model call). An injected LLM
5
+ resolves novel prompts to a known need id; it never invents URIs, only names the intent —
6
+ "LLM proposes the need, the kernel maps it to capabilities and decides the method."
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable
11
+
12
+ from . import needs as _needs
13
+
14
+ Completer = Callable[[str], str]
15
+
16
+ # keyword → need id (rule-based, cheap path)
17
+ _RULES: list[tuple[tuple[str, ...], str]] = [
18
+ (("arkusz", "spreadsheet", "xlsx", "excel", "tabela"), "office.spreadsheet.create"),
19
+ (("dokument", "document", "artykuł", "article", "biuro", "office", "notatk", "pismo",
20
+ "edytor", "editor", "napisz", "write", "libreoffice", "onlyoffice", "faktur", "invoice"),
21
+ "office.document.create"),
22
+ (("zrzut", "screenshot", "ekran", "screen capture", "capture"), "desktop.screenshot"),
23
+ ]
24
+
25
+
26
+ def classify(prompt: str, llm: Completer | None = None) -> _needs.NeedSpec | None:
27
+ """Return the NeedSpec for a prompt. Rule-based first; LLM fallback names the need id."""
28
+ low = (prompt or "").lower()
29
+ for keywords, need_id in _RULES:
30
+ if any(k in low for k in keywords):
31
+ return _needs.get(need_id)
32
+ if llm:
33
+ try:
34
+ catalog = ", ".join(_needs.CATALOG)
35
+ reply = llm(f"Map this request to ONE need id from [{catalog}] or 'none'. "
36
+ f"Reply with only the id.\nRequest: {prompt}").strip().strip('"')
37
+ return _needs.get(reply)
38
+ except Exception: # noqa: BLE001 - a bad LLM reply just means 'unclassified'
39
+ return None
40
+ return None
@@ -0,0 +1,60 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """NeedSpec — the contract of an INTENT, not a URI. Models the goal, not the app.
3
+
4
+ "Test office on lenovo" is not "launch ONLYOFFICE" — it is "produce a document artifact
5
+ and verify it exists". Modeling the need (not the means) is what lets the planner reframe
6
+ away from a missing GUI editor to a headless path. Each need lists the capabilities that
7
+ would satisfy it (preferred → acceptable) and whether a GUI is genuinely required.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class NeedSpec:
16
+ id: str
17
+ goal: str
18
+ required_outputs: tuple[str, ...] = () # artifact/postcondition globs proving success
19
+ preferred_capabilities: tuple[str, ...] = () # best (headless, verifiable) first
20
+ acceptable_capabilities: tuple[str, ...] = () # fallbacks that still satisfy the goal
21
+ forbidden_methods: tuple[str, ...] = ()
22
+ requires_gui: bool = False
23
+ destructive: bool = False
24
+
25
+ def capabilities(self) -> list[str]:
26
+ return list(self.preferred_capabilities) + list(self.acceptable_capabilities)
27
+
28
+
29
+ # --- the needs catalog -----------------------------------------------------------
30
+ OFFICE_DOCUMENT = NeedSpec(
31
+ id="office.document.create",
32
+ goal="Create a document-like artifact and verify it exists — no GUI editor required.",
33
+ required_outputs=("artifact://*/documents/*", "fs://*/file/query/stat"),
34
+ preferred_capabilities=("document.create", "sheet.write", "invoice.render"),
35
+ acceptable_capabilities=("markdown.write", "html.write", "browser.document_edit"),
36
+ requires_gui=False,
37
+ )
38
+
39
+ SPREADSHEET = NeedSpec(
40
+ id="office.spreadsheet.create",
41
+ goal="Create a spreadsheet artifact and read it back.",
42
+ required_outputs=("artifact://*/documents/*.xlsx", "fs://*/file/query/stat"),
43
+ preferred_capabilities=("sheet.write",),
44
+ acceptable_capabilities=("markdown.write",),
45
+ requires_gui=False,
46
+ )
47
+
48
+ SCREENSHOT = NeedSpec(
49
+ id="desktop.screenshot",
50
+ goal="Capture the screen of a node.",
51
+ required_outputs=("artifact://*/screenshots/*",),
52
+ preferred_capabilities=("screen.capture",),
53
+ requires_gui=False,
54
+ )
55
+
56
+ CATALOG: dict[str, NeedSpec] = {n.id: n for n in (OFFICE_DOCUMENT, SPREADSHEET, SCREENSHOT)}
57
+
58
+
59
+ def get(need_id: str) -> NeedSpec | None:
60
+ return CATALOG.get(need_id)
@@ -0,0 +1,119 @@
1
+ # Author: Tom Sapletta · Part of the ifURI solution.
2
+ """The deterministic resolution ladder — LLM proposes, the kernel decides.
3
+
4
+ Given a NeedSpec and a context (what the node serves, what the host serves, what is
5
+ installable, whether the node can be managed), pick the FIRST feasible strategy in cost
6
+ order — never install blindly, never reach for a GUI when a headless path exists:
7
+
8
+ 1. DIRECT a capability is already served on the node (cheapest, headless)
9
+ 2. HOST_FALLBACK a headless capability runs host-side + sync (no node change)
10
+ 3. INSTALL a connector provides it and the node can manage (fixes the node)
11
+ 4. GUI a GUI capability, only as a last resort
12
+ 5. BLOCKED nothing feasible → honest, with what was tried
13
+
14
+ Every plan carries a postcondition (from the need's required_outputs) so the verifier
15
+ can confirm the artifact actually exists.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ from . import capabilities as _caps
23
+ from . import gates as _gates
24
+ from . import needs as _needs
25
+
26
+
27
+ @dataclass
28
+ class Context:
29
+ node: str
30
+ node_served: list[str] = field(default_factory=list)
31
+ host_served: list[str] = field(default_factory=list)
32
+ installable: set = field(default_factory=set)
33
+ can_manage: bool = False
34
+ allow_gui: bool = False
35
+
36
+
37
+ def _postcondition(need: _needs.NeedSpec, node: str) -> dict | None:
38
+ for out in need.required_outputs:
39
+ if out.startswith("fs://") or out.startswith("artifact://"):
40
+ return {"uri": out.replace("*", node, 1) if "{node}" not in out else out.replace("{node}", node),
41
+ "check": "exists"}
42
+ return None
43
+
44
+
45
+ def _direct(need, ctx) -> dict | None:
46
+ for cap_id in need.capabilities():
47
+ cap = _caps.get(cap_id)
48
+ if not cap or cap.gui:
49
+ continue
50
+ route = _caps.served_route(cap_id, ctx.node, ctx.node_served)
51
+ if route:
52
+ return {"strategy": "direct", "capability": cap_id,
53
+ "steps": [{"uri": route, "for": cap_id}]}
54
+ return None
55
+
56
+
57
+ def _host_fallback(need, ctx) -> dict | None:
58
+ for cap_id in need.capabilities():
59
+ cap = _caps.get(cap_id)
60
+ if not cap or cap.gui or not cap.host_ok:
61
+ continue
62
+ route = _caps.served_route(cap_id, "host", ctx.host_served)
63
+ if route:
64
+ return {"strategy": "host_fallback", "capability": cap_id,
65
+ "steps": [{"uri": route, "for": cap_id, "note": "run on host, then sync to node"}]}
66
+ return None
67
+
68
+
69
+ def _install(need, ctx) -> dict | None:
70
+ if not ctx.can_manage:
71
+ return None
72
+ for cap_id in need.capabilities():
73
+ cap = _caps.get(cap_id)
74
+ if not cap or cap.gui:
75
+ continue
76
+ for conn in cap.connectors:
77
+ if conn in ctx.installable:
78
+ n = ctx.node
79
+ return {"strategy": "install_then_run", "capability": cap_id, "connector": conn,
80
+ "steps": [
81
+ {"uri": f"node://{n}/connector/command/install", "payload": {"id": conn}},
82
+ {"uri": f"node://{n}/registry/command/rebuild"},
83
+ {"uri": f"node://{n}/runtime/command/restart", "note": "drop stale workers"},
84
+ {"uri": f"node://{n}/smoke/command/run"},
85
+ {"uri": _caps.install_route(cap_id, n), "for": cap_id},
86
+ ]}
87
+ return None
88
+
89
+
90
+ def _gui(need, ctx) -> dict | None:
91
+ if not ctx.allow_gui:
92
+ return None
93
+ for cap_id in need.capabilities():
94
+ cap = _caps.get(cap_id)
95
+ if cap and cap.gui:
96
+ route = _caps.served_route(cap_id, ctx.node, ctx.node_served)
97
+ if route:
98
+ return {"strategy": "gui", "capability": cap_id, "steps": [{"uri": route, "for": cap_id}]}
99
+ return None
100
+
101
+
102
+ # strategy ladder, cheapest/safest first — GUI last, blind install never
103
+ _LADDER = (_direct, _host_fallback, _install, _gui)
104
+
105
+
106
+ def resolve(need: _needs.NeedSpec, ctx: Context) -> dict[str, Any]:
107
+ """Walk the ladder; return the first feasible plan (+ postcondition), else blocked."""
108
+ tried = []
109
+ for strat in _LADDER:
110
+ plan = strat(need, ctx)
111
+ if plan:
112
+ plan["steps"] = _gates.apply(plan["steps"]) # insert approval:// before gated steps
113
+ plan["need"] = need.id
114
+ plan["postcondition"] = _postcondition(need, ctx.node)
115
+ plan["resolved"] = True
116
+ return plan
117
+ tried.append(strat.__name__.lstrip("_"))
118
+ return {"resolved": False, "need": need.id, "tried": tried,
119
+ "reason": "no served route, no host fallback, no installable connector, GUI not allowed/available"}
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: urirun-reasoner
3
+ Version: 0.1.0
4
+ Summary: Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback.
5
+ Author: Tom Sapletta
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # urirun-reasoner
11
+
12
+ Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
13
+
14
+ A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
15
+ "test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
16
+ "produce a document artifact and verify it exists" and resolves it through the cheapest
17
+ feasible URI path.
18
+
19
+ ## The resolution ladder (kernel decides, cheapest/safest first)
20
+
21
+ 1. **direct** — a capability is already served on the node (headless)
22
+ 2. **host_fallback** — a headless capability runs host-side + sync (no node change)
23
+ 3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
24
+ 4. **gui** — a GUI capability, only as a last resort
25
+ 5. **blocked** — nothing feasible → honest, with what was tried
26
+
27
+ GUI is never chosen when a headless path exists; connectors are never installed blindly.
28
+
29
+ ## Layers
30
+
31
+ | module | role |
32
+ |---|---|
33
+ | `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
34
+ | `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
35
+ | `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
36
+ | `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
37
+
38
+ ## Use
39
+
40
+ ```python
41
+ from urirun_reasoner import Context, resolve_prompt
42
+ ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
43
+ installable={"sheet"}, can_manage=True)
44
+ plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
45
+ # → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
46
+ ```
47
+
48
+ Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
49
+ (so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_reasoner.py
4
+ urirun_reasoner/__init__.py
5
+ urirun_reasoner/capabilities.py
6
+ urirun_reasoner/cli.py
7
+ urirun_reasoner/gates.py
8
+ urirun_reasoner/generation_policy.py
9
+ urirun_reasoner/generator.py
10
+ urirun_reasoner/intent.py
11
+ urirun_reasoner/needs.py
12
+ urirun_reasoner/planner.py
13
+ urirun_reasoner.egg-info/PKG-INFO
14
+ urirun_reasoner.egg-info/SOURCES.txt
15
+ urirun_reasoner.egg-info/dependency_links.txt
16
+ urirun_reasoner.egg-info/entry_points.txt
17
+ urirun_reasoner.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ urirun-reasoner = urirun_reasoner.cli:main
@@ -0,0 +1 @@
1
+ urirun_reasoner