charms-core 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,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+
12
+ # Node
13
+ node_modules/
14
+ web/dist/
15
+
16
+ # Runtime state
17
+ *.db
18
+ *.db-journal
19
+ .env
20
+ logs/
21
+ .rune_pids
22
+ seed_store/
23
+
24
+ # Model artifacts / caches
25
+ seed-cache/
26
+ checkpoints/
27
+ *.db-shm
28
+ *.db-wal
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: charms-core
3
+ Version: 0.1.0
4
+ Summary: Charms shared contracts: node & seed SDK, recipes, chunks, wire protocol, seed packages
5
+ Project-URL: Repository, https://github.com/ArioAtlas/charms-launcher
6
+ Author: ArioAtlas
7
+ Keywords: ai,charms,distributed-computing,sdk,workflow
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: pydantic>=2.7
16
+ Description-Content-Type: text/markdown
17
+
18
+ # charms-core
19
+
20
+ Shared contracts for the **Charms** platform — a community-powered AI
21
+ workflow system where users design **Charms** (node-graph **Recipes**) and
22
+ run them on a distributed fleet of **Runes**: instances of model-loading
23
+ **Seeds** contributed by users who share their compute and earn **Mana**.
24
+
25
+ This package is the dependency-light foundation both the Charms server and
26
+ the [charms-launcher](https://github.com/ArioAtlas/charms-launcher) build
27
+ on. Pure Pydantic v2 models plus stdlib — no web framework, no database, no
28
+ model runtimes.
29
+
30
+ ## What's inside
31
+
32
+ | Module | Contents |
33
+ |---|---|
34
+ | `charms_core.types` | Modalities, port schemas, node metadata, the `CharmsError` hierarchy |
35
+ | `charms_core.node` | `BasicNode` — the in-process glue-node SDK |
36
+ | `charms_core.seed` | `Seed` — the model-module SDK (`load()` / `run()` / streaming trio), manifests, descriptors |
37
+ | `charms_core.package` | The seed package format: `manifest.json` models (typed options, prices, env rules, install indexes) + zip validation |
38
+ | `charms_core.recipe` | Recipe graph models, validation, topological sort |
39
+ | `charms_core.chunk` | The streaming chunk model + binary framing codec |
40
+ | `charms_core.protocol` | Every WebSocket message on the launcher and realtime-client wires |
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install charms-core
46
+ ```
47
+
48
+ Python ≥ 3.12. Fully typed (`py.typed`).
49
+
50
+ ## Building a Seed
51
+
52
+ ```python
53
+ from pydantic import BaseModel
54
+ from charms_core.seed import Seed, SeedContext, SeedManifest
55
+ from charms_core.types import Modality, PortSchema
56
+
57
+ class EchoInput(BaseModel):
58
+ text: str
59
+
60
+ class EchoOutput(BaseModel):
61
+ text: str
62
+
63
+ class EchoSeed(Seed[EchoInput, EchoOutput, None]):
64
+ manifest = SeedManifest(id="echo", name="Echo")
65
+ inputs = [PortSchema(name="text", modality=Modality.TEXT)]
66
+ outputs = [PortSchema(name="text", modality=Modality.TEXT)]
67
+ input_model = EchoInput
68
+ output_model = EchoOutput
69
+
70
+ async def load(self, ctx: SeedContext) -> None: ...
71
+
72
+ async def run(self, input: EchoInput, config: None = None) -> EchoOutput:
73
+ return EchoOutput(text=input.text)
74
+ ```
75
+
76
+ Package it (with a `manifest.json`, `pyproject.toml`, and `README.md`) and
77
+ import it into a Charms server's seed registry; contributors then pull and
78
+ run it as a Rune via the launcher.
@@ -0,0 +1,61 @@
1
+ # charms-core
2
+
3
+ Shared contracts for the **Charms** platform — a community-powered AI
4
+ workflow system where users design **Charms** (node-graph **Recipes**) and
5
+ run them on a distributed fleet of **Runes**: instances of model-loading
6
+ **Seeds** contributed by users who share their compute and earn **Mana**.
7
+
8
+ This package is the dependency-light foundation both the Charms server and
9
+ the [charms-launcher](https://github.com/ArioAtlas/charms-launcher) build
10
+ on. Pure Pydantic v2 models plus stdlib — no web framework, no database, no
11
+ model runtimes.
12
+
13
+ ## What's inside
14
+
15
+ | Module | Contents |
16
+ |---|---|
17
+ | `charms_core.types` | Modalities, port schemas, node metadata, the `CharmsError` hierarchy |
18
+ | `charms_core.node` | `BasicNode` — the in-process glue-node SDK |
19
+ | `charms_core.seed` | `Seed` — the model-module SDK (`load()` / `run()` / streaming trio), manifests, descriptors |
20
+ | `charms_core.package` | The seed package format: `manifest.json` models (typed options, prices, env rules, install indexes) + zip validation |
21
+ | `charms_core.recipe` | Recipe graph models, validation, topological sort |
22
+ | `charms_core.chunk` | The streaming chunk model + binary framing codec |
23
+ | `charms_core.protocol` | Every WebSocket message on the launcher and realtime-client wires |
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install charms-core
29
+ ```
30
+
31
+ Python ≥ 3.12. Fully typed (`py.typed`).
32
+
33
+ ## Building a Seed
34
+
35
+ ```python
36
+ from pydantic import BaseModel
37
+ from charms_core.seed import Seed, SeedContext, SeedManifest
38
+ from charms_core.types import Modality, PortSchema
39
+
40
+ class EchoInput(BaseModel):
41
+ text: str
42
+
43
+ class EchoOutput(BaseModel):
44
+ text: str
45
+
46
+ class EchoSeed(Seed[EchoInput, EchoOutput, None]):
47
+ manifest = SeedManifest(id="echo", name="Echo")
48
+ inputs = [PortSchema(name="text", modality=Modality.TEXT)]
49
+ outputs = [PortSchema(name="text", modality=Modality.TEXT)]
50
+ input_model = EchoInput
51
+ output_model = EchoOutput
52
+
53
+ async def load(self, ctx: SeedContext) -> None: ...
54
+
55
+ async def run(self, input: EchoInput, config: None = None) -> EchoOutput:
56
+ return EchoOutput(text=input.text)
57
+ ```
58
+
59
+ Package it (with a `manifest.json`, `pyproject.toml`, and `README.md`) and
60
+ import it into a Charms server's seed registry; contributors then pull and
61
+ run it as a Rune via the launcher.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "charms-core"
3
+ version = "0.1.0"
4
+ description = "Charms shared contracts: node & seed SDK, recipes, chunks, wire protocol, seed packages"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "ArioAtlas" }]
8
+ keywords = ["charms", "ai", "workflow", "distributed-computing", "sdk"]
9
+ # NOTE: no license declared yet — decide one before the next release.
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = [
19
+ "pydantic>=2.7",
20
+ ]
21
+
22
+ [project.urls]
23
+ # The public home of these contracts (vendored into the launcher repo).
24
+ Repository = "https://github.com/ArioAtlas/charms-launcher"
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/charms_core"]
@@ -0,0 +1,112 @@
1
+ """charms-core — shared contracts for the Charms platform."""
2
+
3
+ from charms_core import protocol
4
+ from charms_core.chunk import Chunk, decode_chunk_message, encode_chunk_message
5
+ from charms_core.node import BasicNode, ConfigT, InputT, OutputT
6
+ from charms_core.package import (
7
+ EnvVarSpec,
8
+ OptionType,
9
+ PriceUnit,
10
+ PyprojectInfo,
11
+ SeedInstallSpec,
12
+ SeedOption,
13
+ SeedPackage,
14
+ SeedPackageManifest,
15
+ SeedPrice,
16
+ load_seed_package,
17
+ validate_environment,
18
+ )
19
+ from charms_core.recipe import (
20
+ Binding,
21
+ Bindings,
22
+ CatalogEntry,
23
+ FieldValue,
24
+ NodeCatalog,
25
+ Recipe,
26
+ RecipeEdge,
27
+ RecipeNode,
28
+ ValidationProblem,
29
+ chunk_path,
30
+ topo_sort,
31
+ validate_recipe,
32
+ )
33
+ from charms_core.seed import (
34
+ ArtifactDownloader,
35
+ Seed,
36
+ SeedArtifact,
37
+ SeedContext,
38
+ SeedDescriptor,
39
+ SeedManifest,
40
+ SeedResources,
41
+ )
42
+ from charms_core.types import (
43
+ CharmsError,
44
+ ConfigurationError,
45
+ IncompatiblePortError,
46
+ InsufficientManaError,
47
+ Modality,
48
+ NodeExecutionError,
49
+ NodeMetadata,
50
+ NoRuneAvailableError,
51
+ PortSchema,
52
+ ProtocolError,
53
+ RecipeValidationError,
54
+ SeedPackageError,
55
+ TaskFailedError,
56
+ modalities_compatible,
57
+ )
58
+
59
+ __all__ = [
60
+ "ArtifactDownloader",
61
+ "BasicNode",
62
+ "Binding",
63
+ "Bindings",
64
+ "CatalogEntry",
65
+ "CharmsError",
66
+ "Chunk",
67
+ "ConfigT",
68
+ "ConfigurationError",
69
+ "EnvVarSpec",
70
+ "FieldValue",
71
+ "IncompatiblePortError",
72
+ "InputT",
73
+ "InsufficientManaError",
74
+ "Modality",
75
+ "NodeCatalog",
76
+ "NodeExecutionError",
77
+ "NodeMetadata",
78
+ "NoRuneAvailableError",
79
+ "OptionType",
80
+ "OutputT",
81
+ "PortSchema",
82
+ "PriceUnit",
83
+ "ProtocolError",
84
+ "PyprojectInfo",
85
+ "Recipe",
86
+ "RecipeEdge",
87
+ "RecipeNode",
88
+ "RecipeValidationError",
89
+ "Seed",
90
+ "SeedArtifact",
91
+ "SeedContext",
92
+ "SeedDescriptor",
93
+ "SeedInstallSpec",
94
+ "SeedManifest",
95
+ "SeedOption",
96
+ "SeedPackage",
97
+ "SeedPackageError",
98
+ "SeedPackageManifest",
99
+ "SeedPrice",
100
+ "SeedResources",
101
+ "TaskFailedError",
102
+ "ValidationProblem",
103
+ "chunk_path",
104
+ "decode_chunk_message",
105
+ "encode_chunk_message",
106
+ "load_seed_package",
107
+ "modalities_compatible",
108
+ "protocol",
109
+ "topo_sort",
110
+ "validate_environment",
111
+ "validate_recipe",
112
+ ]
@@ -0,0 +1,81 @@
1
+ """
2
+ The streaming unit and its wire codec.
3
+
4
+ A ``Chunk`` travels over WebSockets as one JSON text frame; if it carries a
5
+ binary payload the JSON includes ``"binary": true`` and **exactly one** binary
6
+ frame with the raw bytes follows immediately on the same connection. Text
7
+ chunks embed their payload inline and send no binary frame. This module is the
8
+ only place that framing rule is implemented — both the launcher and client
9
+ sockets reuse it.
10
+
11
+ Audio payloads are PCM float32 little-endian mono unless ``meta`` says
12
+ otherwise; ``meta.sample_rate`` is mandatory for audio chunks.
13
+ """
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field, ValidationError, model_validator
19
+
20
+ from charms_core.types import Modality, ProtocolError
21
+
22
+
23
+ class Chunk(BaseModel):
24
+ """One unit of a realtime stream."""
25
+
26
+ stream_id: str
27
+ seq: int = Field(ge=0, description="0-based sequence number within the stream")
28
+ timestamp_ms: float = 0.0
29
+ is_final: bool = False # final marker; may carry a zero-length payload
30
+ modality: Modality
31
+ text: str | None = None # TEXT chunks: payload travels inline in the JSON
32
+ data: bytes | None = Field(default=None, exclude=True) # binary payload, never in JSON
33
+ meta: dict[str, Any] = Field(default_factory=dict)
34
+
35
+ @model_validator(mode="after")
36
+ def _check_payload(self) -> "Chunk":
37
+ if self.text is not None and self.data is not None:
38
+ raise ValueError("a chunk carries either inline text or binary data, not both")
39
+ if self.text is not None and self.modality is not Modality.TEXT:
40
+ raise ValueError("inline text payload requires modality=text")
41
+ if (
42
+ self.modality is Modality.AUDIO
43
+ and self.data is not None
44
+ and "sample_rate" not in self.meta
45
+ ):
46
+ raise ValueError("audio chunks must declare meta.sample_rate")
47
+ return self
48
+
49
+
50
+ def encode_chunk_message(message_type: str, chunk: Chunk) -> tuple[str, bytes | None]:
51
+ """
52
+ Encode a chunk as a wire message.
53
+
54
+ Returns ``(json_text, binary_payload)``; when the payload is not None the
55
+ sender MUST transmit it as the next binary frame on the same connection.
56
+ """
57
+ payload = chunk.model_dump(mode="json")
58
+ payload["type"] = message_type
59
+ payload["binary"] = chunk.data is not None
60
+ return json.dumps(payload), chunk.data
61
+
62
+
63
+ def decode_chunk_message(obj: dict[str, Any], binary: bytes | None) -> Chunk:
64
+ """
65
+ Decode a parsed chunk-message JSON object plus its (optional) binary frame.
66
+
67
+ Raises ProtocolError when framing is broken (a declared binary frame is
68
+ missing, an undeclared one is supplied, or the fields don't validate).
69
+ """
70
+ expects_binary = bool(obj.get("binary", False))
71
+ if expects_binary and binary is None:
72
+ raise ProtocolError("chunk message declared a binary frame but none was supplied")
73
+ if not expects_binary and binary is not None:
74
+ raise ProtocolError("binary frame supplied for a chunk message that declared none")
75
+ fields = {k: v for k, v in obj.items() if k not in ("type", "binary")}
76
+ if binary is not None:
77
+ fields["data"] = binary
78
+ try:
79
+ return Chunk.model_validate(fields)
80
+ except ValidationError as exc:
81
+ raise ProtocolError(f"invalid chunk message: {exc}") from exc
@@ -0,0 +1,91 @@
1
+ """
2
+ BasicNode — the server-executed recipe node.
3
+
4
+ Basic nodes are strictly lightweight glue (the heavy-compute principle):
5
+ no GPU, no model runtimes, no heavy imports, no blocking I/O. Anything
6
+ heavier is a Seed executed remotely by a Rune.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any, ClassVar, Generic, TypeVar
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from charms_core.chunk import Chunk
15
+ from charms_core.types import (
16
+ IncompatiblePortError,
17
+ NodeMetadata,
18
+ PortSchema,
19
+ modalities_compatible,
20
+ )
21
+
22
+ InputT = TypeVar("InputT", bound=BaseModel)
23
+ OutputT = TypeVar("OutputT", bound=BaseModel)
24
+ ConfigT = TypeVar("ConfigT", bound="BaseModel | None")
25
+
26
+
27
+ class BasicNode(ABC, Generic[InputT, OutputT, ConfigT]):
28
+ """
29
+ Atomic in-process processing unit.
30
+
31
+ Generic parameters
32
+ ------------------
33
+ InputT — Pydantic model for data inputs
34
+ OutputT — Pydantic model for data outputs
35
+ ConfigT — Pydantic model for configuration (use ``None`` if not needed)
36
+
37
+ Conventions: ``run()`` never reads env vars or files; errors at the node
38
+ boundary are ``CharmsError`` subclasses; ``Field(description=...)`` doubles
39
+ as the UI tooltip and ``json_schema_extra`` carries UI hints.
40
+ """
41
+
42
+ input_model: ClassVar[type[BaseModel]]
43
+ output_model: ClassVar[type[BaseModel]]
44
+ config_model: ClassVar[type[BaseModel] | None] = None
45
+ supports_streaming: ClassVar[bool] = False # if True, implement transform_chunk()
46
+
47
+ @classmethod
48
+ @abstractmethod
49
+ def metadata(cls) -> NodeMetadata:
50
+ """Return static metadata describing this node's ports."""
51
+
52
+ @abstractmethod
53
+ async def run(self, input: InputT, config: ConfigT | None = None) -> OutputT:
54
+ """Execute this node's logic and return an output."""
55
+
56
+ async def transform_chunk(self, chunk: Chunk) -> Chunk | None:
57
+ """
58
+ Per-chunk transform for streaming-capable basic nodes on a realtime
59
+ chunk path. Return None to swallow a chunk.
60
+ """
61
+ raise NotImplementedError(f"{type(self).__name__} does not support streaming")
62
+
63
+ # ------------------------------------------------------------------ #
64
+ # Port / compatibility helpers #
65
+ # ------------------------------------------------------------------ #
66
+
67
+ @classmethod
68
+ def input_ports(cls) -> list[PortSchema]:
69
+ return cls.metadata().inputs
70
+
71
+ @classmethod
72
+ def output_ports(cls) -> list[PortSchema]:
73
+ return cls.metadata().outputs
74
+
75
+ @classmethod
76
+ def accepts(cls, port: PortSchema) -> bool:
77
+ """Return True if this node has an input port compatible with *port*."""
78
+ return any(modalities_compatible(port.modality, inp.modality) for inp in cls.input_ports())
79
+
80
+ @classmethod
81
+ def assert_compatible(cls, upstream: "type[BasicNode[Any, Any, Any]]") -> None:
82
+ """
83
+ Raise IncompatiblePortError if no output port of *upstream* matches any
84
+ input port of this node.
85
+ """
86
+ for out_port in upstream.output_ports():
87
+ if cls.accepts(out_port):
88
+ return
89
+ raise IncompatiblePortError(
90
+ f"No compatible connection: {upstream.metadata().node_id} → {cls.metadata().node_id}"
91
+ )