induscode 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 (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,66 @@
1
+ """Bridge-ledger — public barrel for event-sourced MCP tool enrollment.
2
+
3
+ Three concerns, one import site:
4
+
5
+ - **Keys** (``.key``) — content-addressed and ULID :data:`BridgeKey` minting,
6
+ so re-enrolling the same external tool is an idempotent upsert.
7
+ - **Ledger** (``.ledger``) — the immutable :class:`BridgeLedger` value and its
8
+ pure transitions: :func:`enroll_bridge_card` (upsert), :func:`retire` /
9
+ :func:`withdraw_server` (splice), each returning a new ledger folded by the
10
+ contract's :func:`reduce_ledger`.
11
+ - **Network** (``.network``) — the side-effecting half:
12
+ :func:`attach_bridge_capabilities` mounting external MCP servers through the
13
+ framework's ``mount_protocol_bridge``, adapting the returned ``ToolBox``
14
+ into ``AgentTool``-shaped capabilities, and enrolling them into a ledger
15
+ (wholesale mount failure surfaces as a ``bridge`` :class:`DeckFault` on the
16
+ :class:`AttachResult`, never raised); :func:`detach_bridge` withdrawing a
17
+ server set and best-effort tearing the fleet down.
18
+
19
+ The frozen shapes (:data:`BridgeKey`, :class:`BridgeEntry`,
20
+ :class:`LedgerSnapshot`, :func:`reduce_ledger`) continue to live in the deck
21
+ contract; this module re-exports the behavior that operates on them.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .key import bridge_content_key, bridge_ulid_key, qualify_bridge_name
27
+ from .ledger import (
28
+ BridgeLedger,
29
+ EnrollRequest,
30
+ bridge_ledger_from_log,
31
+ empty_bridge_ledger,
32
+ enroll_bridge_card,
33
+ live_capabilities,
34
+ live_capabilities_for_server,
35
+ retire,
36
+ withdraw_server,
37
+ )
38
+ from .network import (
39
+ AttachResult,
40
+ attach_bridge_capabilities,
41
+ bridge_box_to_capabilities,
42
+ bridge_capability_card,
43
+ bridge_config,
44
+ detach_bridge,
45
+ )
46
+
47
+ __all__ = [
48
+ "AttachResult",
49
+ "BridgeLedger",
50
+ "EnrollRequest",
51
+ "attach_bridge_capabilities",
52
+ "bridge_box_to_capabilities",
53
+ "bridge_capability_card",
54
+ "bridge_config",
55
+ "bridge_content_key",
56
+ "bridge_ledger_from_log",
57
+ "bridge_ulid_key",
58
+ "detach_bridge",
59
+ "empty_bridge_ledger",
60
+ "enroll_bridge_card",
61
+ "live_capabilities",
62
+ "live_capabilities_for_server",
63
+ "qualify_bridge_name",
64
+ "retire",
65
+ "withdraw_server",
66
+ ]
@@ -0,0 +1,181 @@
1
+ """Bridge-key minting — stable identifiers for enrolled MCP capabilities.
2
+
3
+ An enrolled bridge capability needs a key that is *the same* across sessions
4
+ for the same external tool, so re-enrolling is an idempotent upsert rather
5
+ than an accumulating duplicate. The catalog already settled this policy in the
6
+ contract: a :data:`BridgeKey` is "a content hash of the qualified
7
+ ``<server>__<tool>`` name (and its schema) or a fresh ULID" — keyed by the
8
+ capability's *identity*, never by a working-directory digest.
9
+
10
+ This module implements both halves of that policy and nothing else:
11
+
12
+ - :func:`bridge_content_key` — the default. A deterministic digest of the
13
+ qualified name plus the canonicalized parameter schema. Two enrollments of
14
+ the same remote tool (same name, same schema) collapse to one key, so the
15
+ ledger upsert is naturally idempotent and the live set never duplicates a
16
+ tool just because a server reconnected.
17
+ - :func:`bridge_ulid_key` — an opt-in, monotonic, time-sortable ULID for the
18
+ rare case a caller wants every enrollment to be a distinct event (e.g. an
19
+ ephemeral, per-invocation graft) rather than a deduplicated identity.
20
+
21
+ Both return a branded :data:`BridgeKey`; neither touches the filesystem, the
22
+ clock-as-state, or any shared mutable value.
23
+
24
+ Port note — canonical-JSON parity with the TS lineage
25
+ -----------------------------------------------------
26
+ The content key must hash the same bytes the TS ``canonicalize`` produced, or
27
+ keys minted by the two runtimes diverge (analysis 03 risk 9). The decisions:
28
+
29
+ - **Objects**: keys sorted by UTF-16 code units (the TS ``a < b`` string
30
+ comparison), serialized ``"key":value`` with no whitespace. Entries whose
31
+ value is ``None`` are **dropped** — the Python stand-in for the TS filter
32
+ on ``undefined``-valued entries. (TS kept JSON ``null`` values; Python
33
+ cannot distinguish an in-memory "absent" from a JSON ``null`` once both are
34
+ ``None``, so dict-entry ``None`` is read as *absent*, exactly where — and
35
+ only where — TS dropped ``undefined``.)
36
+ - **Arrays**: order preserved (it is semantic in JSON Schema, e.g.
37
+ ``required`` / ``enum``); ``None`` *elements* serialize as ``null`` — TS
38
+ rendered both ``null`` and ``undefined`` array elements as ``"null"``.
39
+ - **Strings**: ``json.dumps(..., ensure_ascii=False)`` — matches
40
+ ``JSON.stringify`` escaping (quote/backslash/control characters escaped,
41
+ non-ASCII left raw).
42
+ - **Numbers**: integral floats print without the trailing ``.0`` (JS has one
43
+ number type: ``1.0`` stringifies as ``"1"``); non-finite floats print as
44
+ ``null`` (``JSON.stringify(NaN)`` → ``"null"``).
45
+ - **Everything non-JSON** (functions, arbitrary objects) prints as ``null``,
46
+ mirroring ``JSON.stringify(value) ?? "null"`` on unserializable values.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import json
52
+ import os
53
+ import time
54
+ from collections.abc import Mapping, Sequence
55
+ from hashlib import sha256
56
+
57
+ from indusagi.interop import QUALIFIER
58
+ from ulid import ULID
59
+ from ulid import constants as _ulid_constants
60
+
61
+ from ..contract import BridgeKey, Schema, bridge_key
62
+
63
+ __all__ = [
64
+ "bridge_content_key",
65
+ "bridge_ulid_key",
66
+ "qualify_bridge_name",
67
+ ]
68
+
69
+
70
+ def qualify_bridge_name(server: str, tool: str) -> str:
71
+ """The qualified, collision-free name the model sees for a remote tool —
72
+ ``"<server>__<tool>"``, using the framework's bridge :data:`QUALIFIER`.
73
+
74
+ The protocol bridge already stamps this exact form onto each grafted
75
+ tool's descriptor; we recompute it here only so a key can be minted
76
+ *before* a descriptor is in hand (e.g. from a ``RemoteToolRef``-shaped
77
+ pair).
78
+
79
+ :param server: the owning MCP server's id
80
+ :param tool: the remote tool's own (unqualified) name
81
+ """
82
+ return f"{server}{QUALIFIER}{tool}"
83
+
84
+
85
+ def _canonical_scalar(value: object) -> str:
86
+ """Serialize one JSON scalar the way ``JSON.stringify`` would (see the
87
+ module docstring for the number/None decisions)."""
88
+ if value is None:
89
+ return "null"
90
+ if isinstance(value, bool): # before int: bool is an int subclass
91
+ return "true" if value else "false"
92
+ if isinstance(value, int):
93
+ return str(value)
94
+ if isinstance(value, float):
95
+ if value != value or value in (float("inf"), float("-inf")):
96
+ return "null" # JSON.stringify(NaN/Infinity) -> "null"
97
+ if value.is_integer() and abs(value) < 1e21:
98
+ return str(int(value)) # JS prints 1.0 as "1"
99
+ return json.dumps(value)
100
+ if isinstance(value, str):
101
+ return json.dumps(value, ensure_ascii=False)
102
+ # Unserializable (the TS `JSON.stringify(value) ?? "null"` fallback).
103
+ return "null"
104
+
105
+
106
+ def _canonicalize(value: object) -> str:
107
+ """Stably serialize a parameter schema so two structurally-equal schemas
108
+ hash to the same string regardless of key insertion order.
109
+
110
+ A plain ``json.dumps`` of an unsorted mapping is order-sensitive:
111
+ ``{a,b}`` and ``{b,a}`` would digest differently even though they describe
112
+ the same parameters. This walks the value and sorts every object's keys
113
+ (by UTF-16 code units, matching the TS string comparison), yielding a
114
+ canonical form. Arrays keep their order (it is semantic in JSON Schema,
115
+ e.g. ``required``/``enum``). Mapping entries whose value is ``None`` are
116
+ dropped — the stand-in for the TS ``undefined`` filter.
117
+
118
+ :param value: the schema (or any JSON-ish value) to canonicalize
119
+ """
120
+ if isinstance(value, Mapping):
121
+ kept = [(str(k), v) for k, v in value.items() if v is not None]
122
+ kept.sort(key=lambda kv: kv[0].encode("utf-16-be"))
123
+ entries = (
124
+ f"{json.dumps(k, ensure_ascii=False)}:{_canonicalize(v)}" for k, v in kept
125
+ )
126
+ return "{" + ",".join(entries) + "}"
127
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
128
+ return "[" + ",".join(_canonicalize(item) for item in value) + "]"
129
+ return _canonical_scalar(value)
130
+
131
+
132
+ def bridge_content_key(
133
+ server: str,
134
+ tool: str,
135
+ parameters: Schema | None = None,
136
+ ) -> BridgeKey:
137
+ """Mint a content-addressed :data:`BridgeKey` from a remote tool's
138
+ identity.
139
+
140
+ The digest is taken over the qualified ``<server>__<tool>`` name and the
141
+ canonicalized parameter schema, so the key is a pure function of *what the
142
+ tool is*. Enrolling the same tool twice yields the same key — the ledger
143
+ upsert deduplicates it — while a tool whose schema changed yields a new
144
+ key, correctly surfacing it as a distinct capability.
145
+
146
+ :param server: the owning MCP server's id
147
+ :param tool: the remote tool's own (unqualified) name
148
+ :param parameters: the tool's parameter schema (a raw JSON-schema mapping)
149
+ """
150
+ qualified = qualify_bridge_name(server, tool)
151
+ schema_part = "" if parameters is None else _canonicalize(parameters)
152
+ digest = sha256()
153
+ digest.update(qualified.encode("utf-8"))
154
+ digest.update(b" ") # domain separator so name|schema cannot collide with name+schema
155
+ digest.update(schema_part.encode("utf-8"))
156
+ return bridge_key(f"bk_{digest.hexdigest()[:32]}")
157
+
158
+
159
+ def bridge_ulid_key(monotonic: bool = True) -> BridgeKey:
160
+ """Mint a fresh, time-sortable :data:`BridgeKey` as a ULID.
161
+
162
+ Use when each enrollment must be a *distinct* event rather than a
163
+ deduplicated identity — for instance an ephemeral, per-invocation graft,
164
+ or when two differently-configured connections to the same server should
165
+ each get their own live entry. The ULID is monotonic within a process so
166
+ ordering is stable.
167
+
168
+ :param monotonic: when ``True`` (default) use ``python-ulid``'s
169
+ process-monotonic provider so keys minted in the same millisecond
170
+ remain strictly increasing (the TS ``monotonicFactory()``); pass
171
+ ``False`` for a plain, independent ULID built from a fresh timestamp
172
+ and randomness.
173
+ """
174
+ if monotonic:
175
+ # python-ulid's shared ValueProvider increments the randomness when two
176
+ # ULIDs land in the same millisecond — process-monotonic by default.
177
+ return bridge_key(f"bk_{ULID()}")
178
+ raw = (time.time_ns() // 1_000_000).to_bytes(
179
+ _ulid_constants.TIMESTAMP_LEN, "big"
180
+ ) + os.urandom(_ulid_constants.RANDOMNESS_LEN)
181
+ return bridge_key(f"bk_{ULID(raw)}")
@@ -0,0 +1,276 @@
1
+ """The immutable bridge ledger — event-sourced enrollment of MCP capabilities.
2
+
3
+ MCP tools are grafted in and pulled out over a session's life: a server
4
+ connects and contributes a handful of tools, another disconnects, the same
5
+ server reconnects after a hot-reload. Rather than mutate a shared module-level
6
+ list of live tools (which makes "who's enrolled right now" a function of call
7
+ order and hides every past change), enrollment here is an **append-only event
8
+ log** folded into a **derived snapshot**.
9
+
10
+ - :class:`BridgeLedger` is a plain immutable value: the ordered
11
+ :class:`BridgeEntry` log, its already-folded :class:`LedgerSnapshot`, and
12
+ the next sequence number to stamp.
13
+ - Every mutation (:func:`enroll_bridge_card`, :func:`retire`,
14
+ :func:`withdraw_server`) returns a *new* ledger; the input is never touched.
15
+ ``enroll`` is an upsert (re-enrolling a key replaces its entry), ``retire``
16
+ is a splice (the keyed entry is removed from the live view).
17
+ - The live view is produced solely by the contract's pure
18
+ :func:`reduce_ledger` fold, so the snapshot can never drift from the log.
19
+
20
+ The log is the source of truth; the snapshot is a cache of its fold. Because
21
+ both live on the value, callers read ``ledger.snapshot.live`` directly and
22
+ never re-reduce by hand.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Sequence
28
+ from dataclasses import dataclass
29
+ from datetime import datetime, timezone
30
+
31
+ from ..contract import (
32
+ AnyCapability,
33
+ BridgeEntry,
34
+ BridgeKey,
35
+ LedgerSnapshot,
36
+ reduce_ledger,
37
+ )
38
+ from .key import bridge_content_key
39
+
40
+ __all__ = [
41
+ "BridgeLedger",
42
+ "EnrollRequest",
43
+ "bridge_ledger_from_log",
44
+ "empty_bridge_ledger",
45
+ "enroll_bridge_card",
46
+ "live_capabilities",
47
+ "live_capabilities_for_server",
48
+ "retire",
49
+ "withdraw_server",
50
+ ]
51
+
52
+
53
+ @dataclass(frozen=True, slots=True, kw_only=True)
54
+ class BridgeLedger:
55
+ """An immutable, event-sourced view of MCP tool enrollment.
56
+
57
+ Holds the append-only :class:`BridgeEntry` log, the
58
+ :class:`LedgerSnapshot` that is its current fold, and the sequence number
59
+ the next appended entry will carry. Treat every instance as frozen: the
60
+ enroll/retire/withdraw helpers derive a new ledger rather than editing
61
+ this one in place.
62
+ """
63
+
64
+ # The append-only enrollment event log, in append order.
65
+ log: tuple[BridgeEntry, ...]
66
+ # The current fold of `log` — the live capabilities and per-server counts.
67
+ snapshot: LedgerSnapshot
68
+ # The sequence number the next appended `BridgeEntry` will carry.
69
+ next_seq: int
70
+
71
+
72
+ @dataclass(frozen=True, slots=True, kw_only=True)
73
+ class EnrollRequest:
74
+ """The fields an enrollment supplies; the ledger stamps ``op``, ``seq``,
75
+ and ``at``.
76
+
77
+ A caller names the capability, the owning server, and optionally a
78
+ pre-minted :data:`BridgeKey`; when the key is omitted it is
79
+ content-addressed from the capability's identity so re-enrolling the same
80
+ tool is idempotent.
81
+ """
82
+
83
+ # The grafted capability to enroll (its `name` is the qualified tool name).
84
+ capability: AnyCapability
85
+ # Id of the external MCP server that owns the capability.
86
+ server: str
87
+ # Optional explicit key; content-addressed from the capability when omitted.
88
+ key: BridgeKey | None = None
89
+
90
+
91
+ def _now_iso() -> str:
92
+ """An ISO-8601 UTC timestamp in the TS ``Date.toISOString()`` form."""
93
+ return (
94
+ datetime.now(timezone.utc)
95
+ .isoformat(timespec="milliseconds")
96
+ .replace("+00:00", "Z")
97
+ )
98
+
99
+
100
+ def empty_bridge_ledger() -> BridgeLedger:
101
+ """An empty ledger: no events, an empty live view, sequence numbering at 1."""
102
+ return BridgeLedger(log=(), snapshot=reduce_ledger(()), next_seq=1)
103
+
104
+
105
+ def bridge_ledger_from_log(log: Sequence[BridgeEntry]) -> BridgeLedger:
106
+ """Rebuild a ledger value from a persisted event log.
107
+
108
+ The log is authoritative, so a ledger is fully reconstructable from it:
109
+ fold it for the snapshot and resume sequence numbering one past its
110
+ high-water mark. Use when rehydrating enrollment state across a restart.
111
+
112
+ :param log: the persisted append-only entries, in any order (sorted on fold)
113
+ """
114
+ snapshot = reduce_ledger(log)
115
+ high_water = 0
116
+ for entry in log:
117
+ if entry.seq > high_water:
118
+ high_water = entry.seq
119
+ return BridgeLedger(log=tuple(log), snapshot=snapshot, next_seq=high_water + 1)
120
+
121
+
122
+ def _resolve_key(req: EnrollRequest) -> BridgeKey:
123
+ """Resolve the key for an :class:`EnrollRequest`: the caller's explicit
124
+ key, or a content key derived from the capability's identity."""
125
+ if req.key is not None:
126
+ return req.key
127
+ cap = req.capability
128
+ # `cap.name` is already the qualified "<server>__<tool>" the bridge stamped
129
+ # on; pairing it with the schema makes the key a pure function of the
130
+ # tool's shape.
131
+ return bridge_content_key(req.server, cap.name, cap.parameters)
132
+
133
+
134
+ def enroll_bridge_card(
135
+ ledger: BridgeLedger,
136
+ req: EnrollRequest,
137
+ at: str | None = None,
138
+ ) -> BridgeLedger:
139
+ """Append an ``enroll`` event and return the resulting ledger.
140
+
141
+ The graft is an **upsert**: the appended entry carries a
142
+ :data:`BridgeKey`, and because :func:`reduce_ledger` lets a later sequence
143
+ win on a repeated key, a re-enrollment of the same tool transparently
144
+ replaces its prior live entry rather than duplicating it. The input ledger
145
+ is not mutated.
146
+
147
+ :param ledger: the current ledger (left untouched)
148
+ :param req: the capability + server to enroll, with an optional explicit key
149
+ :param at: enrollment timestamp; defaults to now (ISO-8601)
150
+ """
151
+ key = _resolve_key(req)
152
+ entry = BridgeEntry(
153
+ op="enroll",
154
+ key=key,
155
+ server=req.server,
156
+ capability=req.capability,
157
+ seq=ledger.next_seq,
158
+ at=at if at is not None else _now_iso(),
159
+ )
160
+ return _append_entry(ledger, entry)
161
+
162
+
163
+ def retire(
164
+ ledger: BridgeLedger,
165
+ key: BridgeKey,
166
+ server: str,
167
+ at: str | None = None,
168
+ ) -> BridgeLedger:
169
+ """Append a ``retire`` event for one capability and return the resulting
170
+ ledger.
171
+
172
+ The withdrawal is a **splice**: the appended ``retire`` entry names the
173
+ key, and the fold drops that key from the live view. Retiring an unknown
174
+ or already-retired key is a harmless no-op in the live set (the event is
175
+ still recorded for the audit trail). The input ledger is not mutated.
176
+
177
+ :param ledger: the current ledger (left untouched)
178
+ :param key: the stable key of the capability to withdraw
179
+ :param server: the owning server id, recorded on the event
180
+ :param at: retirement timestamp; defaults to now (ISO-8601)
181
+ """
182
+ entry = BridgeEntry(
183
+ op="retire",
184
+ key=key,
185
+ server=server,
186
+ seq=ledger.next_seq,
187
+ at=at if at is not None else _now_iso(),
188
+ )
189
+ return _append_entry(ledger, entry)
190
+
191
+
192
+ def withdraw_server(
193
+ ledger: BridgeLedger,
194
+ server: str,
195
+ at: str | None = None,
196
+ ) -> BridgeLedger:
197
+ """Retire every capability a given server currently has live, in one batch.
198
+
199
+ Appends one ``retire`` event per live key owned by the server — the
200
+ disconnect counterpart to grafting a whole server's tool set. Servers with
201
+ nothing live yield the ledger unchanged. The input ledger is not mutated.
202
+
203
+ :param ledger: the current ledger (left untouched)
204
+ :param server: the server whose live capabilities should all be withdrawn
205
+ :param at: retirement timestamp applied to every event; defaults to now
206
+ """
207
+ owned = _live_keys_for_server(ledger, server)
208
+ if not owned:
209
+ return ledger
210
+ stamp = at if at is not None else _now_iso()
211
+ seq = ledger.next_seq
212
+ events: list[BridgeEntry] = []
213
+ for key in owned:
214
+ events.append(BridgeEntry(op="retire", key=key, server=server, seq=seq, at=stamp))
215
+ seq += 1
216
+ log = (*ledger.log, *events)
217
+ return BridgeLedger(log=log, snapshot=reduce_ledger(log), next_seq=seq)
218
+
219
+
220
+ def live_capabilities_for_server(
221
+ ledger: BridgeLedger, server: str
222
+ ) -> list[AnyCapability]:
223
+ """Read the live capabilities a single server currently contributes.
224
+
225
+ Pure projection over the snapshot; the per-key→server association is
226
+ recovered from the log (each live key's last-touching server). Handy for
227
+ status panels and for :func:`withdraw_server`'s batch retirement.
228
+
229
+ :param ledger: the ledger to read
230
+ :param server: the server id to filter by
231
+ """
232
+ out: list[AnyCapability] = []
233
+ for key in _live_keys_for_server(ledger, server):
234
+ cap = ledger.snapshot.live.get(key)
235
+ if cap is not None:
236
+ out.append(cap)
237
+ return out
238
+
239
+
240
+ def live_capabilities(ledger: BridgeLedger) -> list[AnyCapability]:
241
+ """The flat list of every live capability, in stable iteration order.
242
+
243
+ What a deck assembler grafts onto the static catalog before handing the
244
+ conductor its ``options.tools``.
245
+ """
246
+ return list(ledger.snapshot.live.values())
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # internals
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _append_entry(ledger: BridgeLedger, entry: BridgeEntry) -> BridgeLedger:
255
+ """Append one stamped entry and re-fold; the single mutation primitive."""
256
+ log = (*ledger.log, entry)
257
+ return BridgeLedger(log=log, snapshot=reduce_ledger(log), next_seq=entry.seq + 1)
258
+
259
+
260
+ def _live_keys_for_server(ledger: BridgeLedger, server: str) -> list[BridgeKey]:
261
+ """The live keys a server owns, derived from the log: a key is the
262
+ server's when the server's last touch on that key is the entry the fold
263
+ settled on. We map each key to the server of its highest-seq entry, then
264
+ keep the live ones."""
265
+ last_server: dict[BridgeKey, str] = {}
266
+ last_seq: dict[BridgeKey, int] = {}
267
+ for entry in ledger.log:
268
+ seen = last_seq.get(entry.key)
269
+ if seen is None or entry.seq >= seen:
270
+ last_seq[entry.key] = entry.seq
271
+ last_server[entry.key] = entry.server
272
+ out: list[BridgeKey] = []
273
+ for key in ledger.snapshot.live:
274
+ if last_server.get(key) == server:
275
+ out.append(key)
276
+ return out