rememberstack 0.1.0__py3-none-any.whl

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.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,185 @@
1
+ """The recipe executor (D50): replay a registry chain over the primitives.
2
+
3
+ A recipe is *exactly* its chain — it adds no capability an agent could not
4
+ compose from the §3 primitives itself. This executor is where that becomes
5
+ literally true: it walks the chain, calls each named primitive on the
6
+ `QueryEngine` with the recipe's frozen settings and the caller's bound
7
+ arguments, threads earlier steps' rankings into `fuse`, and returns the last
8
+ step's envelope. Because the executor calls the same public methods an agent
9
+ would, "recipe ≡ hand-composed chain" is a property the eval harness proves by
10
+ running both and diffing (retrieval §4).
11
+
12
+ The executor implements a handler per op in use; the linter (`core`) has
13
+ already rejected any chain that names an unknown op or misreports its grain,
14
+ so a chain that reaches the executor is well-formed.
15
+ """
16
+
17
+ from typing import Any
18
+ from uuid import UUID
19
+
20
+ from rememberstack.model import Envelope
21
+ from rememberstack.model import Recipe
22
+ from rememberstack.model import RecipeStep
23
+ from rememberstack.surfaces.query_engine import QueryEngine
24
+
25
+
26
+ class RecipeExecutionError(Exception):
27
+ """A chain reached the executor with an op it has no handler for."""
28
+
29
+
30
+ class RecipeExecutor:
31
+ """Replay a recipe's frozen chain over the zero-LLM query primitives."""
32
+
33
+ def __init__(self, *, query_engine: QueryEngine) -> None:
34
+ """Bind the executor to the query engine whose primitives it composes."""
35
+ self._engine = query_engine
36
+
37
+ def execute(
38
+ self, *, deployment_id: UUID, recipe: Recipe, arguments: dict[str, object]
39
+ ) -> Envelope:
40
+ """Run the recipe's chain and return the final step's envelope.
41
+
42
+ Each step's settings (frozen by the recipe) and bound arguments
43
+ (supplied by the caller) become the primitive's keywords; `fuse`
44
+ pulls the orderings of the steps it references. The last step's
45
+ envelope is the recipe's answer.
46
+ """
47
+ envelopes: list[Envelope] = []
48
+ rankings: list[list[UUID]] = []
49
+ for step in recipe.chain:
50
+ envelope = self._run_step(
51
+ deployment_id=deployment_id,
52
+ step=step,
53
+ arguments=arguments,
54
+ rankings=rankings,
55
+ )
56
+ envelopes.append(envelope)
57
+ rankings.append(_ranking_of(envelope))
58
+ return envelopes[-1]
59
+
60
+ def _run_step(
61
+ self,
62
+ *,
63
+ deployment_id: UUID,
64
+ step: RecipeStep,
65
+ arguments: dict[str, object],
66
+ rankings: list[list[UUID]],
67
+ ) -> Envelope:
68
+ """Dispatch one chain step to its primitive with resolved keywords.
69
+
70
+ A bound argument the caller omitted is simply not passed — the
71
+ primitive's own default applies, so an optional recipe parameter
72
+ (a missing `predicate`, say) behaves exactly as calling the
73
+ primitive without it, never a KeyError.
74
+ """
75
+ kwargs: dict[str, Any] = dict(step.settings)
76
+ for primitive_kw, argument_name in step.bind.items():
77
+ if argument_name in arguments:
78
+ kwargs[primitive_kw] = arguments[argument_name]
79
+ if step.op == "fuse":
80
+ return self._engine.fuse(
81
+ rankings=[rankings[index] for index in step.inputs], **kwargs
82
+ )
83
+ handler = _SINGLE_OP_HANDLERS.get(step.op)
84
+ if handler is None:
85
+ raise RecipeExecutionError(
86
+ f"the executor has no handler for op {step.op!r}"
87
+ )
88
+ return handler(self._engine, deployment_id, kwargs)
89
+
90
+
91
+ def _ranking_of(envelope: Envelope) -> list[UUID]:
92
+ """The ordered ids of an envelope's payload — what `fuse` consumes.
93
+
94
+ A step's downstream ranking is whichever id list the envelope carries, in
95
+ the order it carries it: a fused/reranked order, then evidence, facts,
96
+ entities, graph nodes, changes, or pages. This is what lets `fuse`
97
+ compose the outputs of heterogeneous upstream primitives.
98
+ """
99
+ if envelope.ranking:
100
+ return [item.item_id for item in envelope.ranking]
101
+ if envelope.evidence:
102
+ return [record.claim_id for record in envelope.evidence]
103
+ if envelope.facts:
104
+ return [fact.fact_id for fact in envelope.facts]
105
+ if envelope.entities:
106
+ return [candidate.entity_id for candidate in envelope.entities]
107
+ if envelope.nodes:
108
+ return [node.entity_id for node in envelope.nodes]
109
+ if envelope.changes:
110
+ return [change.id for change in envelope.changes]
111
+ if envelope.pages:
112
+ return [page.artifact_id for page in envelope.pages]
113
+ return []
114
+
115
+
116
+ def _lookup_relations(
117
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
118
+ ) -> Envelope:
119
+ """The `lookup_relations` op."""
120
+ return engine.lookup_relations(deployment_id=deployment_id, **kwargs)
121
+
122
+
123
+ def _lookup_observations(
124
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
125
+ ) -> Envelope:
126
+ """The `lookup_observations` op."""
127
+ return engine.lookup_observations(deployment_id=deployment_id, **kwargs)
128
+
129
+
130
+ def _aggregate(
131
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
132
+ ) -> Envelope:
133
+ """The `aggregate` op."""
134
+ return engine.aggregate(deployment_id=deployment_id, **kwargs)
135
+
136
+
137
+ def _search_claims(
138
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
139
+ ) -> Envelope:
140
+ """The `search_claims` op."""
141
+ return engine.search_claims(deployment_id=deployment_id, **kwargs)
142
+
143
+
144
+ def _hydrate_relation(
145
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
146
+ ) -> Envelope:
147
+ """The `hydrate_relation` op."""
148
+ return engine.hydrate_relation(deployment_id=deployment_id, **kwargs)
149
+
150
+
151
+ def _transcript(
152
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
153
+ ) -> Envelope:
154
+ """The `transcript` op."""
155
+ return engine.transcript(deployment_id=deployment_id, **kwargs)
156
+
157
+
158
+ def _delta(
159
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
160
+ ) -> Envelope:
161
+ """The `delta` op."""
162
+ return engine.delta(deployment_id=deployment_id, **kwargs)
163
+
164
+
165
+ def _pages_about(
166
+ engine: QueryEngine, deployment_id: UUID, kwargs: dict[str, Any]
167
+ ) -> Envelope:
168
+ """The `pages_about` op."""
169
+ return engine.pages_about(deployment_id=deployment_id, **kwargs)
170
+
171
+
172
+ _SINGLE_OP_HANDLERS = {
173
+ "lookup_relations": _lookup_relations,
174
+ "lookup_observations": _lookup_observations,
175
+ "aggregate": _aggregate,
176
+ "search_claims": _search_claims,
177
+ "hydrate_relation": _hydrate_relation,
178
+ "transcript": _transcript,
179
+ "delta": _delta,
180
+ "pages_about": _pages_about,
181
+ }
182
+
183
+ EXECUTABLE_OPS = frozenset(_SINGLE_OP_HANDLERS) | {"fuse"}
184
+ """Every op the executor can run. Kept equal to the linter's `KNOWN_OPS` (a
185
+ test enforces it), so no chain ever lints clean only to fail at execution."""
@@ -0,0 +1,219 @@
1
+ """The recipe surface (retrieval §7): one rendering of the registry for all.
2
+
3
+ The API, CLI, and MCP surfaces must expose the SAME recipes — "the MCP tool
4
+ list renders from the recipe registry; the CLI mirrors the API 1:1" — so the
5
+ rendering and dispatch live here, once, and each surface is a thin transport
6
+ over it. That is how parity is a property, not a promise: there is a single
7
+ place that turns a registry row into a callable tool.
8
+
9
+ Two responsibilities:
10
+
11
+ - **Render** each active recipe as a `ToolDescriptor` — name, description,
12
+ and a real JSON-Schema `input_schema` built from the recipe's typed
13
+ parameters (this is what an MCP `tools/list` returns, and what the API
14
+ advertises at `/recipes`).
15
+ - **Run** a recipe by name: coerce the caller's string-ish arguments to the
16
+ types the primitives need (a uuid string to a UUID, an ISO instant to a
17
+ datetime), then hand them to the `RecipeExecutor`. Coercion is the surface's
18
+ job precisely because every transport delivers arguments as text.
19
+ """
20
+
21
+ from datetime import datetime
22
+ from datetime import UTC
23
+ from typing import Any
24
+ from uuid import UUID
25
+
26
+ from rememberstack.model import Envelope
27
+ from rememberstack.model import Recipe
28
+ from rememberstack.model.client import ToolDescriptor
29
+ from rememberstack.spine.recipes import RecipeRegistry
30
+ from rememberstack.surfaces.recipe_executor import RecipeExecutor
31
+
32
+
33
+ class UnknownRecipeError(Exception):
34
+ """A recipe name the registry has no active row for."""
35
+
36
+
37
+ class MissingArgumentError(Exception):
38
+ """A required recipe parameter the caller did not supply."""
39
+
40
+
41
+ class InvalidArgumentError(Exception):
42
+ """An argument the caller supplied is the wrong type or not a parameter."""
43
+
44
+
45
+ # How a recipe's declared parameter type renders into JSON Schema, and how a
46
+ # transport's text argument coerces back to what the primitives expect.
47
+ _TYPE_SCHEMA: dict[str, dict[str, object]] = {
48
+ "uuid": {"type": "string", "format": "uuid"},
49
+ "string": {"type": "string"},
50
+ "integer": {"type": "integer"},
51
+ "timestamp": {"type": "string", "format": "date-time"},
52
+ }
53
+
54
+
55
+ def _coerce_uuid(value: object) -> UUID:
56
+ """Coerce a transport argument to a UUID."""
57
+ return value if isinstance(value, UUID) else UUID(str(value))
58
+
59
+
60
+ def _coerce_integer(value: object) -> int:
61
+ """Coerce a transport argument to an int without a silent truncation."""
62
+ if isinstance(value, bool): # bool is an int subclass — never a count
63
+ raise ValueError("expected an integer, got a boolean")
64
+ if isinstance(value, float):
65
+ if not value.is_integer():
66
+ raise ValueError(f"expected an integer, got {value!r}")
67
+ return int(value)
68
+ return int(str(value))
69
+
70
+
71
+ def _coerce_timestamp(value: object) -> datetime:
72
+ """Coerce a transport argument to a UTC datetime (ISO 8601).
73
+
74
+ The envelope's timestamps are UTC-only, so a naive instant is read as
75
+ UTC and an offset instant is normalized to UTC — never passed through
76
+ with a stray offset that a downstream model would reject.
77
+ """
78
+ parsed = (
79
+ value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
80
+ )
81
+ if parsed.tzinfo is None:
82
+ return parsed.replace(tzinfo=UTC)
83
+ return parsed.astimezone(UTC)
84
+
85
+
86
+ _COERCERS: dict[str, Any] = {
87
+ "uuid": _coerce_uuid,
88
+ "string": str,
89
+ "integer": _coerce_integer,
90
+ "timestamp": _coerce_timestamp,
91
+ }
92
+
93
+
94
+ class RecipeSurface:
95
+ """Render and run the deployment's recipes — the shared surface logic."""
96
+
97
+ def __init__(
98
+ self, *, registry: RecipeRegistry, executor: RecipeExecutor, deployment_id: UUID
99
+ ) -> None:
100
+ """Bind the surface to the registry, the executor, and the deployment."""
101
+ self._registry = registry
102
+ self._executor = executor
103
+ self._deployment_id = deployment_id
104
+
105
+ @property
106
+ def deployment_id(self) -> UUID:
107
+ """The one deployment this surface serves (a composition guard, D50)."""
108
+ return self._deployment_id
109
+
110
+ def descriptors(self) -> tuple[ToolDescriptor, ...]:
111
+ """The recipe tool list: ONE tool per name — the latest active version.
112
+
113
+ `run` resolves a name to its latest active version, so the tool list
114
+ advertises exactly that: a deployment with v1 and v2 both active shows
115
+ one `relation_current`, whose schema is the one that will execute.
116
+ """
117
+ seen: set[str] = set()
118
+ descriptors: list[ToolDescriptor] = []
119
+ for recipe in self._registry.active(deployment_id=self._deployment_id):
120
+ if recipe.name in seen: # active() is name, version DESC — first wins
121
+ continue
122
+ seen.add(recipe.name)
123
+ descriptors.append(_descriptor(recipe))
124
+ return tuple(descriptors)
125
+
126
+ def run(self, *, name: str, arguments: dict[str, object]) -> Envelope:
127
+ """Run one recipe by name over coerced arguments.
128
+
129
+ Raises `UnknownRecipeError` if no active row exists,
130
+ `MissingArgumentError` if a required parameter is absent, and
131
+ `InvalidArgumentError` if an argument is not a declared parameter or
132
+ will not coerce to its declared type — the surfaces map each to a
133
+ typed failure (a 404/422, or an MCP error result), never a crash.
134
+ """
135
+ recipe = self._registry.by_name(deployment_id=self._deployment_id, name=name)
136
+ if recipe is None:
137
+ raise UnknownRecipeError(name)
138
+ return self._executor.execute(
139
+ deployment_id=self._deployment_id,
140
+ recipe=recipe,
141
+ arguments=_coerce_arguments(recipe=recipe, arguments=arguments),
142
+ )
143
+
144
+
145
+ def _descriptor(recipe: Recipe) -> ToolDescriptor:
146
+ """Render one recipe as a JSON-Schema-carrying tool descriptor.
147
+
148
+ Each property carries its type plus any declared facets (`default`,
149
+ `enum`) so a client sees the whole contract; `additionalProperties` is
150
+ false so a mistyped argument name (`predciate`) is a schema violation a
151
+ validating client rejects, never a silently-dropped filter.
152
+ """
153
+ properties: dict[str, object] = {}
154
+ required: list[str] = []
155
+ for name, spec in recipe.parameters.items():
156
+ declared = spec if isinstance(spec, dict) else {}
157
+ rendered = dict(_TYPE_SCHEMA.get(str(declared.get("type")), {"type": "string"}))
158
+ for facet in ("default", "enum", "description"):
159
+ if facet in declared:
160
+ rendered[facet] = declared[facet]
161
+ properties[name] = rendered
162
+ if declared.get("required"):
163
+ required.append(name)
164
+ schema: dict[str, object] = {
165
+ "type": "object",
166
+ "properties": properties,
167
+ "additionalProperties": False,
168
+ }
169
+ if required:
170
+ schema["required"] = required
171
+ return ToolDescriptor(
172
+ name=recipe.name,
173
+ description=recipe.description,
174
+ input_schema=schema,
175
+ output_grain=recipe.output_grain.value,
176
+ answer_intent=recipe.answer_intent.value,
177
+ )
178
+
179
+
180
+ def _coerce_arguments(
181
+ *, recipe: Recipe, arguments: dict[str, object]
182
+ ) -> dict[str, object]:
183
+ """Coerce transport arguments to the types the primitives expect.
184
+
185
+ Every rule fails loudly rather than silently changing the query: an
186
+ argument that is not a declared parameter is an InvalidArgumentError (a
187
+ typo never broadens the query); a required parameter absent (or null) is
188
+ a MissingArgumentError; a declared optional's `default` is applied when
189
+ the caller omits it; and a value that will not coerce to its declared
190
+ type is an InvalidArgumentError, never an uncaught crash.
191
+ """
192
+ declared_names = set(recipe.parameters)
193
+ unknown = set(arguments) - declared_names
194
+ if unknown:
195
+ raise InvalidArgumentError(
196
+ f"recipe {recipe.name!r} has no parameter(s) {sorted(unknown)}"
197
+ )
198
+ coerced: dict[str, object] = {}
199
+ for name, spec in recipe.parameters.items():
200
+ declared = spec if isinstance(spec, dict) else {}
201
+ value = arguments.get(name)
202
+ if value is None:
203
+ if declared.get("required"):
204
+ raise MissingArgumentError(
205
+ f"recipe {recipe.name!r} requires argument {name!r}"
206
+ )
207
+ if "default" in declared:
208
+ value = declared["default"]
209
+ else:
210
+ continue
211
+ coerce = _COERCERS.get(str(declared.get("type")), str)
212
+ try:
213
+ coerced[name] = coerce(value)
214
+ except (ValueError, TypeError) as error:
215
+ raise InvalidArgumentError(
216
+ f"argument {name!r} of recipe {recipe.name!r} is not a valid"
217
+ f" {declared.get('type', 'string')}: {value!r}"
218
+ ) from error
219
+ return coerced
@@ -0,0 +1,133 @@
1
+ """MCP protocol logic backed by the remote typed SDK, safe in the base wheel."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import TextIO
6
+
7
+ from rememberstack import __version__
8
+ from rememberstack.surfaces.sdk import MemoryApiError
9
+ from rememberstack.surfaces.sdk import MemoryClient
10
+
11
+ MCP_PROTOCOL_VERSION = "2025-11-25"
12
+
13
+
14
+ class RemoteRecipeMcpServer:
15
+ """Render remote deployment recipes as MCP tools and proxy calls to it."""
16
+
17
+ def __init__(self, *, client: MemoryClient) -> None:
18
+ self._client = client
19
+
20
+ def list_tools(self) -> dict[str, object]:
21
+ """The MCP ``tools/list`` result from the deployment registry."""
22
+ return {
23
+ "tools": [
24
+ {
25
+ "name": descriptor.name,
26
+ "description": descriptor.description,
27
+ "inputSchema": descriptor.input_schema,
28
+ }
29
+ for descriptor in self._client.recipes()
30
+ ]
31
+ }
32
+
33
+ def call_tool(
34
+ self, *, name: str, arguments: dict[str, object]
35
+ ) -> dict[str, object]:
36
+ """The MCP ``tools/call`` result containing one envelope JSON block."""
37
+ try:
38
+ envelope = self._client.run_recipe(name=name, arguments=arguments)
39
+ except (MemoryApiError, ValueError) as error:
40
+ return {"content": [{"type": "text", "text": str(error)}], "isError": True}
41
+ return {
42
+ "content": [{"type": "text", "text": envelope.model_dump_json()}],
43
+ "isError": False,
44
+ }
45
+
46
+
47
+ def serve_mcp_stdio(
48
+ *,
49
+ server: RemoteRecipeMcpServer,
50
+ input_stream: TextIO = sys.stdin,
51
+ output_stream: TextIO = sys.stdout,
52
+ ) -> int:
53
+ """Serve the minimal MCP JSON-RPC lifecycle over newline-delimited stdio."""
54
+ for line in input_stream:
55
+ try:
56
+ request = json.loads(line)
57
+ except json.JSONDecodeError as error:
58
+ response = {
59
+ "jsonrpc": "2.0",
60
+ "id": None,
61
+ "error": {"code": -32700, "message": str(error)},
62
+ }
63
+ else:
64
+ if not isinstance(request, dict):
65
+ response = _rpc_error(
66
+ request_id=None, code=-32600, message="request is not an object"
67
+ )
68
+ else:
69
+ try:
70
+ response = _dispatch(server=server, request=request)
71
+ except (MemoryApiError, ValueError, TypeError) as error:
72
+ response = _rpc_error(
73
+ request_id=request.get("id"), code=-32603, message=str(error)
74
+ )
75
+ if response is not None:
76
+ output_stream.write(json.dumps(response) + "\n")
77
+ output_stream.flush()
78
+ return 0
79
+
80
+
81
+ def _dispatch(
82
+ *, server: RemoteRecipeMcpServer, request: dict[str, object]
83
+ ) -> dict[str, object] | None:
84
+ """Dispatch one MCP request; notifications deliberately have no response."""
85
+ request_id = request.get("id")
86
+ method = request.get("method")
87
+ if request.get("jsonrpc") != "2.0" or not isinstance(method, str):
88
+ return _rpc_error(
89
+ request_id=request_id, code=-32600, message="invalid JSON-RPC request"
90
+ )
91
+ if "id" not in request:
92
+ return None
93
+ if method == "initialize":
94
+ params = request.get("params")
95
+ if not isinstance(params, dict) or not isinstance(
96
+ params.get("protocolVersion"), str
97
+ ):
98
+ return _rpc_error(
99
+ request_id=request_id, code=-32602, message="bad initialize params"
100
+ )
101
+ result: dict[str, object] = {
102
+ "protocolVersion": MCP_PROTOCOL_VERSION,
103
+ "capabilities": {"tools": {}},
104
+ "serverInfo": {"name": "rememberstack", "version": __version__},
105
+ }
106
+ elif method == "ping":
107
+ result = {}
108
+ elif method == "tools/list":
109
+ result = server.list_tools()
110
+ elif method == "tools/call":
111
+ params = request.get("params")
112
+ if not isinstance(params, dict) or not isinstance(params.get("name"), str):
113
+ return _rpc_error(request_id=request_id, code=-32602, message="bad params")
114
+ arguments = params.get("arguments", {})
115
+ if not isinstance(arguments, dict):
116
+ return _rpc_error(
117
+ request_id=request_id, code=-32602, message="bad arguments"
118
+ )
119
+ result = server.call_tool(name=params["name"], arguments=arguments)
120
+ else:
121
+ return _rpc_error(
122
+ request_id=request_id, code=-32601, message=f"unknown method {method!r}"
123
+ )
124
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
125
+
126
+
127
+ def _rpc_error(*, request_id: object, code: int, message: str) -> dict[str, object]:
128
+ """Build one JSON-RPC error response."""
129
+ return {
130
+ "jsonrpc": "2.0",
131
+ "id": request_id,
132
+ "error": {"code": code, "message": message},
133
+ }