@ai-matrx/desktop-protocol 0.7.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +23 -0
- package/dist/client.cjs +287 -30
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +48 -3
- package/dist/client.d.ts +48 -3
- package/dist/client.js +287 -30
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +747 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +757 -5
- package/dist/index.d.ts +757 -5
- package/dist/index.js +747 -5
- package/dist/index.js.map +1 -1
- package/dist/{registry-Duc_VFN8.d.cts → models-CxAHZMAM.d.cts} +1515 -3
- package/dist/{registry-Duc_VFN8.d.ts → models-CxAHZMAM.d.ts} +1515 -3
- package/dist/react.d.cts +286 -1
- package/dist/react.d.ts +286 -1
- package/generated/desktop-protocol.schema.json +4554 -0
- package/package.json +3 -1
- package/scripts/gen_pydantic.py +335 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-matrx/desktop-protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The one wire contract between Matrx 2 (the desktop app) and every client that drives it — phone, web app, extension, server: zod schemas for the envelope and every cap.op, the 16-byte binary frame codec, a reconnecting request/stream/credit/reattach client, React hooks, and a generated JSON Schema + Pydantic twin that CI keeps byte-identical.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"dist",
|
|
31
|
+
"generated/desktop-protocol.schema.json",
|
|
32
|
+
"scripts/gen_pydantic.py",
|
|
31
33
|
"README.md",
|
|
32
34
|
"LICENSE",
|
|
33
35
|
"CHANGELOG.md"
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Generate the Pydantic v2 twin of @ai-matrx/desktop-protocol from its JSON Schema.
|
|
2
|
+
|
|
3
|
+
Called by ``pnpm generate`` (scripts/generate.ts) and by aidream's pytest contract check. Stdlib
|
|
4
|
+
only; the one external tool is datamodel-codegen, pinned and run through ``uvx`` so every machine
|
|
5
|
+
and CI job produces byte-identical output (``--formatters builtin``: no black/isort version drift).
|
|
6
|
+
|
|
7
|
+
Two post-processing steps make the generated module a faithful twin of the zod source:
|
|
8
|
+
|
|
9
|
+
1. ``StrictModel`` — the generated base of every object model. datamodel-codegen renders an
|
|
10
|
+
optional, NON-nullable field (zod ``.optional()``) as ``X | None = None``, so Pydantic accepts
|
|
11
|
+
an explicit ``null`` that zod refuses (parity corpus case 19). The base refuses an explicit
|
|
12
|
+
``None`` for every field whose default is ``None`` and whose type is not ``Any``. That rule is
|
|
13
|
+
exact because generate.ts refuses any schema property that is BOTH optional and nullable.
|
|
14
|
+
2. ``OPS`` / ``EVENTS`` — the op and event registries (from the schema's ``x-ops``/``x-events``),
|
|
15
|
+
so the server routes and validates by the same table the TypeScript Router uses.
|
|
16
|
+
3. ``SERVICES`` and, per supervised Python service (``x-services``), its handler ``Protocol``:
|
|
17
|
+
``<Name>ServiceHandlers`` has one ``async`` method per op, typed with that op's generated
|
|
18
|
+
models, so ``pyright --strict`` fails a service whose handlers drift from the contract.
|
|
19
|
+
``CONTRACT_SHA256`` is the schema file's hash, exchanged in the service hello.
|
|
20
|
+
|
|
21
|
+
Usage: gen_pydantic.py --schema <desktop-protocol.schema.json> --out <desktop_protocol.py>
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
CODEGEN_VERSION = "0.83.0"
|
|
36
|
+
CODEGEN_ARGS = [
|
|
37
|
+
"--input-file-type",
|
|
38
|
+
"jsonschema",
|
|
39
|
+
"--output-model-type",
|
|
40
|
+
"pydantic_v2.BaseModel",
|
|
41
|
+
# The PLAN.md flags (accepted 2026-10-01):
|
|
42
|
+
"--strict-nullable",
|
|
43
|
+
"--extra-fields",
|
|
44
|
+
"forbid",
|
|
45
|
+
"--enum-field-as-literal",
|
|
46
|
+
"all",
|
|
47
|
+
"--use-annotated",
|
|
48
|
+
# Determinism:
|
|
49
|
+
"--target-python-version",
|
|
50
|
+
"3.13",
|
|
51
|
+
"--disable-timestamp",
|
|
52
|
+
"--formatters",
|
|
53
|
+
"builtin",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
HEADER = '''# ruff: noqa
|
|
57
|
+
# AUTO-GENERATED — DO NOT EDIT.
|
|
58
|
+
# Source: aidream/apps/shared/desktop-protocol (@ai-matrx/desktop-protocol, zod), via
|
|
59
|
+
# `pnpm generate` → generated/desktop-protocol.schema.json → datamodel-codegen {version}
|
|
60
|
+
# → scripts/gen_pydantic.py. `pnpm generate --check` and
|
|
61
|
+
# aidream/_generated/tests/test_desktop_protocol_contract.py fail on any hand edit.
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
STRICT_BASE = '''
|
|
65
|
+
|
|
66
|
+
class StrictModel(BaseModel):
|
|
67
|
+
"""Generated base of every object model: refuses an explicit ``null`` for an optional field.
|
|
68
|
+
|
|
69
|
+
zod ``.optional()`` means "may be absent", never "may be null"; datamodel-codegen still types
|
|
70
|
+
such a field ``X | None = None``. A field whose default is ``None`` and whose type is not
|
|
71
|
+
``Any`` is optional-non-nullable by construction (generate.ts refuses optional+nullable), so
|
|
72
|
+
an explicit ``None`` for it is rejected exactly as zod rejects it.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="before")
|
|
76
|
+
@classmethod
|
|
77
|
+
def _refuse_explicit_null(cls, data: Any) -> Any:
|
|
78
|
+
if isinstance(data, dict):
|
|
79
|
+
for name, field in cls.model_fields.items():
|
|
80
|
+
key = field.alias or name
|
|
81
|
+
if (
|
|
82
|
+
key in data
|
|
83
|
+
and data[key] is None
|
|
84
|
+
and not field.is_required()
|
|
85
|
+
and field.default is None
|
|
86
|
+
and field.annotation is not Any
|
|
87
|
+
):
|
|
88
|
+
raise ValueError(f"{key}: explicit null is not allowed (omit the field instead)")
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _js_number(text: str) -> float | int:
|
|
93
|
+
value = float(text)
|
|
94
|
+
return int(value) if value.is_integer() and abs(value) <= 9007199254740991 else value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_wire[M: BaseModel](model: type[M], raw: str | bytes) -> M:
|
|
98
|
+
"""THE way to validate one wire message in Python — the verdict matches zod's exactly.
|
|
99
|
+
|
|
100
|
+
Strict mode (no "2" -> 2 coercion, as zod), plus JavaScript's one number type: JSON.parse
|
|
101
|
+
makes ``2.0`` the integer 2, so an integral float is an integer here too.
|
|
102
|
+
"""
|
|
103
|
+
data = json.loads(raw, parse_float=_js_number)
|
|
104
|
+
return model.model_validate_json(json.dumps(data), strict=True)
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_codegen(schema: Path, out: Path) -> str:
|
|
109
|
+
cmd = [
|
|
110
|
+
"uvx",
|
|
111
|
+
"--from",
|
|
112
|
+
f"datamodel-code-generator=={CODEGEN_VERSION}",
|
|
113
|
+
"datamodel-codegen",
|
|
114
|
+
"--input",
|
|
115
|
+
str(schema),
|
|
116
|
+
"--output",
|
|
117
|
+
str(out),
|
|
118
|
+
*CODEGEN_ARGS,
|
|
119
|
+
]
|
|
120
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
121
|
+
if proc.returncode != 0:
|
|
122
|
+
sys.stderr.write(proc.stdout + proc.stderr)
|
|
123
|
+
raise SystemExit(f"datamodel-codegen {CODEGEN_VERSION} failed (exit {proc.returncode})")
|
|
124
|
+
return out.read_text(encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def post_process(code: str, schema_doc: dict, contract_sha256: str) -> str:
|
|
128
|
+
lines = code.splitlines()
|
|
129
|
+
# Drop codegen's own banner (it names a temp path on some machines); ours replaces it.
|
|
130
|
+
while lines and lines[0].startswith("#"):
|
|
131
|
+
lines.pop(0)
|
|
132
|
+
code = "\n".join(lines).lstrip("\n") + "\n"
|
|
133
|
+
|
|
134
|
+
# Imports the base needs.
|
|
135
|
+
m = re.search(r"^from pydantic import (.+)$", code, flags=re.M)
|
|
136
|
+
if m is None:
|
|
137
|
+
raise SystemExit("gen_pydantic: no `from pydantic import` line — codegen output changed shape")
|
|
138
|
+
names = {n.strip() for n in m.group(1).split(",")} | {"BaseModel", "RootModel", "model_validator"}
|
|
139
|
+
code = code[: m.start()] + "from pydantic import " + ", ".join(sorted(names)) + code[m.end() :]
|
|
140
|
+
m = re.search(r"^from typing import (.+)$", code, flags=re.M)
|
|
141
|
+
if m is None:
|
|
142
|
+
raise SystemExit("gen_pydantic: no `from typing import` line — codegen output changed shape")
|
|
143
|
+
names = {n.strip() for n in m.group(1).split(",")} | {"Any", "Literal", "NamedTuple", "Protocol"}
|
|
144
|
+
code = code[: m.start()] + "from typing import " + ", ".join(sorted(names)) + code[m.end() :]
|
|
145
|
+
|
|
146
|
+
# `constr(...)` in a type position (record keys) is a call in a type expression — pyright
|
|
147
|
+
# rejects it. Rewrite to the equivalent Annotated[str, StringConstraints(...)].
|
|
148
|
+
code = rewrite_constr(code)
|
|
149
|
+
|
|
150
|
+
# Every object model inherits the strict base.
|
|
151
|
+
count = len(re.findall(r"^class \w+\(BaseModel\):", code, flags=re.M))
|
|
152
|
+
if count == 0:
|
|
153
|
+
raise SystemExit("gen_pydantic: no BaseModel classes found — codegen output changed shape")
|
|
154
|
+
code = re.sub(r"^(class \w+)\(BaseModel\):", r"\1(StrictModel):", code, flags=re.M)
|
|
155
|
+
|
|
156
|
+
# Insert the base right after the import block (before the first class).
|
|
157
|
+
first_class = re.search(r"^class ", code, flags=re.M)
|
|
158
|
+
assert first_class is not None
|
|
159
|
+
head, tail = code[: first_class.start()].rstrip("\n"), code[first_class.start() :]
|
|
160
|
+
if "from __future__ import annotations\n" not in head:
|
|
161
|
+
raise SystemExit("gen_pydantic: no __future__ import — codegen output changed shape")
|
|
162
|
+
head = head.replace("from __future__ import annotations\n", "from __future__ import annotations\n\nimport json\n", 1)
|
|
163
|
+
code = head + "\n" + STRICT_BASE + "\n\n" + tail
|
|
164
|
+
|
|
165
|
+
code = code.rstrip("\n") + "\n" + registries(schema_doc) + services(schema_doc, contract_sha256)
|
|
166
|
+
return HEADER.format(version=CODEGEN_VERSION) + "\n" + code
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def rewrite_constr(code: str) -> str:
|
|
170
|
+
out: list[str] = []
|
|
171
|
+
i = 0
|
|
172
|
+
found = False
|
|
173
|
+
while (j := code.find("constr(", i)) != -1:
|
|
174
|
+
if j > 0 and (code[j - 1].isalnum() or code[j - 1] == "_"):
|
|
175
|
+
out.append(code[i : j + 7])
|
|
176
|
+
i = j + 7
|
|
177
|
+
continue
|
|
178
|
+
found = True
|
|
179
|
+
depth, k, quote = 0, j + 6, None
|
|
180
|
+
while True:
|
|
181
|
+
ch = code[k]
|
|
182
|
+
if quote:
|
|
183
|
+
if ch == "\\":
|
|
184
|
+
k += 2
|
|
185
|
+
continue
|
|
186
|
+
if ch == quote:
|
|
187
|
+
quote = None
|
|
188
|
+
elif ch in "'\"":
|
|
189
|
+
quote = ch
|
|
190
|
+
elif ch == "(":
|
|
191
|
+
depth += 1
|
|
192
|
+
elif ch == ")":
|
|
193
|
+
depth -= 1
|
|
194
|
+
if depth == 0:
|
|
195
|
+
break
|
|
196
|
+
k += 1
|
|
197
|
+
out.append(code[i:j] + "Annotated[str, StringConstraints(" + code[j + 7 : k] + ")]")
|
|
198
|
+
i = k + 1
|
|
199
|
+
out.append(code[i:])
|
|
200
|
+
code = "".join(out)
|
|
201
|
+
if found:
|
|
202
|
+
code = re.sub(r"^(from pydantic import .*?)\bconstr, ", r"\1", code, count=1, flags=re.M)
|
|
203
|
+
code = re.sub(r"^(from pydantic import .*?), constr$", r"\1", code, count=1, flags=re.M)
|
|
204
|
+
m = re.search(r"^from pydantic import (.+)$", code, flags=re.M)
|
|
205
|
+
assert m is not None
|
|
206
|
+
names = {n.strip() for n in m.group(1).split(",")} | {"StringConstraints"}
|
|
207
|
+
code = code[: m.start()] + "from pydantic import " + ", ".join(sorted(names)) + code[m.end() :]
|
|
208
|
+
return code
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def registries(doc: dict) -> str:
|
|
212
|
+
ops = doc.get("x-ops", {})
|
|
213
|
+
events = doc.get("x-events", {})
|
|
214
|
+
out = [
|
|
215
|
+
"",
|
|
216
|
+
"",
|
|
217
|
+
"# ── Registries (from x-ops / x-events) ──────────────────────────────────────────────",
|
|
218
|
+
"",
|
|
219
|
+
"",
|
|
220
|
+
"class OpSpec(NamedTuple):",
|
|
221
|
+
" params: type[BaseModel]",
|
|
222
|
+
" result: type[BaseModel] | type[RootModel[Any]]",
|
|
223
|
+
" kind: Literal['unary', 'stream', 'resource']",
|
|
224
|
+
" since: str",
|
|
225
|
+
" errors: tuple[str, ...]",
|
|
226
|
+
"",
|
|
227
|
+
"",
|
|
228
|
+
"OPS: dict[str, OpSpec] = {",
|
|
229
|
+
]
|
|
230
|
+
for name, spec in ops.items():
|
|
231
|
+
errs = ", ".join(repr(e) for e in spec["errors"])
|
|
232
|
+
errs = f"({errs},)" if len(spec["errors"]) == 1 else f"({errs})"
|
|
233
|
+
out.append(
|
|
234
|
+
f" {name!r}: OpSpec({spec['params']}, {spec['result']}, {spec['kind']!r}, {spec['since']!r}, {errs}),"
|
|
235
|
+
)
|
|
236
|
+
out += ["}", "", "EVENTS: dict[str, type[BaseModel]] = {"]
|
|
237
|
+
for name, model in events.items():
|
|
238
|
+
out.append(f" {name!r}: {model},")
|
|
239
|
+
out.append("}")
|
|
240
|
+
out += [
|
|
241
|
+
"",
|
|
242
|
+
f"#: The newest protocol this contract speaks (\"major.minor\"); announce it, negotiate down.",
|
|
243
|
+
f"PROTOCOL_VERSION = {doc.get('x-protocol-version', '1.0')!r}",
|
|
244
|
+
"",
|
|
245
|
+
"#: Fields added after x.0, by schema location (src/versioning.ts): optional on read; a reader",
|
|
246
|
+
"#: that needs one checks the peer's negotiated version and says so when the peer is older.",
|
|
247
|
+
"FIELDS_SINCE: dict[str, dict[str, str]] = {",
|
|
248
|
+
]
|
|
249
|
+
for path, fields in doc.get("x-fields-since", {}).items():
|
|
250
|
+
out.append(f" {path!r}: {{{', '.join(f'{k!r}: {v!r}' for k, v in fields.items())}}},")
|
|
251
|
+
out.append("}")
|
|
252
|
+
out += [
|
|
253
|
+
"",
|
|
254
|
+
"#: 1.0 fields a later minor lets be null, and what an older peer is sent instead.",
|
|
255
|
+
"NULL_SINCE: dict[str, dict[str, Any]] = {",
|
|
256
|
+
]
|
|
257
|
+
for path, rule in doc.get("x-null-since", {}).items():
|
|
258
|
+
out.append(f" {path!r}: {{'since': {rule['since']!r}, 'older_peers_get': {rule['olderPeersGet']!r}}},")
|
|
259
|
+
out.append("}")
|
|
260
|
+
return "\n".join(out) + "\n"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def services(doc: dict, contract_sha256: str) -> str:
|
|
264
|
+
out = [
|
|
265
|
+
"",
|
|
266
|
+
"",
|
|
267
|
+
"# ── Supervised Python services (from x-services) ────────────────────────────────────",
|
|
268
|
+
"",
|
|
269
|
+
"#: SHA-256 of the JSON Schema this module was generated from. The core sends its own in the",
|
|
270
|
+
"#: service hello; a service built from a different contract refuses the connection.",
|
|
271
|
+
f"CONTRACT_SHA256 = {contract_sha256!r}",
|
|
272
|
+
"",
|
|
273
|
+
"",
|
|
274
|
+
"class ServiceOpSpec(NamedTuple):",
|
|
275
|
+
" params: type[BaseModel]",
|
|
276
|
+
" result: type[BaseModel]",
|
|
277
|
+
" #: The handler method that serves the op (the op name after the service prefix).",
|
|
278
|
+
" method: str",
|
|
279
|
+
"",
|
|
280
|
+
"",
|
|
281
|
+
]
|
|
282
|
+
table: list[str] = ["SERVICES: dict[str, dict[str, ServiceOpSpec]] = {"]
|
|
283
|
+
progress: list[str] = ["SERVICE_PROGRESS: dict[str, type[BaseModel]] = {"]
|
|
284
|
+
for service, spec in doc.get("x-services", {}).items():
|
|
285
|
+
cls = "".join(part.capitalize() for part in service.split("_"))
|
|
286
|
+
out += [
|
|
287
|
+
f"class {cls}ServiceCall(Protocol):",
|
|
288
|
+
f' """What one `{service}` handler may do besides return its result."""',
|
|
289
|
+
"",
|
|
290
|
+
" @property",
|
|
291
|
+
" def cancelled(self) -> bool: ...",
|
|
292
|
+
"",
|
|
293
|
+
f" def progress(self, payload: {spec['progress']}, /) -> None: ...",
|
|
294
|
+
"",
|
|
295
|
+
"",
|
|
296
|
+
f"class {cls}ServiceHandlers(Protocol):",
|
|
297
|
+
f' """The `{service}` service implements exactly these ops (pyright --strict holds it to the contract)."""',
|
|
298
|
+
"",
|
|
299
|
+
]
|
|
300
|
+
table.append(f" {service!r}: {{")
|
|
301
|
+
for op, o in spec["ops"].items():
|
|
302
|
+
prefix = f"{service}."
|
|
303
|
+
if not op.startswith(prefix):
|
|
304
|
+
raise SystemExit(f"gen_pydantic: service op {op} does not start with {prefix}")
|
|
305
|
+
method = op[len(prefix) :]
|
|
306
|
+
if not method.isidentifier():
|
|
307
|
+
raise SystemExit(f"gen_pydantic: service op {op} is not a valid method name")
|
|
308
|
+
out.append(
|
|
309
|
+
f" async def {method}(self, params: {o['params']}, call: {cls}ServiceCall, /) -> {o['result']}: ..."
|
|
310
|
+
)
|
|
311
|
+
table.append(f" {op!r}: ServiceOpSpec({o['params']}, {o['result']}, {method!r}),")
|
|
312
|
+
out += ["", ""]
|
|
313
|
+
table.append(" },")
|
|
314
|
+
progress.append(f" {service!r}: {spec['progress']},")
|
|
315
|
+
table.append("}")
|
|
316
|
+
progress.append("}")
|
|
317
|
+
out += table + ["", ""] + progress
|
|
318
|
+
return "\n".join(out) + "\n"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main() -> None:
|
|
322
|
+
ap = argparse.ArgumentParser()
|
|
323
|
+
ap.add_argument("--schema", required=True, type=Path)
|
|
324
|
+
ap.add_argument("--out", required=True, type=Path)
|
|
325
|
+
args = ap.parse_args()
|
|
326
|
+
raw_schema = args.schema.read_bytes()
|
|
327
|
+
doc = json.loads(raw_schema.decode("utf-8"))
|
|
328
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
329
|
+
raw = run_codegen(args.schema, Path(tmp) / "raw.py")
|
|
330
|
+
code = post_process(raw, doc, hashlib.sha256(raw_schema).hexdigest())
|
|
331
|
+
args.out.write_text(code, encoding="utf-8")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|