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,382 @@
1
+ """signal-hub — the conductor's product-event hub + framework-event translator.
2
+
3
+ Two cooperating pieces sitting between the framework ``Agent`` loop and the
4
+ conductor's consumers:
5
+
6
+ - :class:`SignalHub` — a synchronous, insertion-ordered fan-out bus over the
7
+ :data:`SessionSignal` stream. Behavior modules emit into it; UI/print/RPC
8
+ consumers subscribe to it.
9
+ - :func:`translate_agent_event` — the pure projection that turns a single
10
+ framework ``AgentEvent`` into the zero-or-more :data:`SessionSignal` values
11
+ that flow through the hub. :data:`TRANSLATOR_TABLE` is the underlying
12
+ dispatch table (exported for inspection/testing).
13
+
14
+ Typical wiring: subscribe the framework loop, run each raw event through
15
+ ``translate_agent_event``, and ``emit`` the results on a :class:`SignalHub`.
16
+
17
+ Hub design (deliberately the conductor's own, not the framework's loop
18
+ emitter; ported from TS ``src/conductor/signal-hub/hub.ts``):
19
+
20
+ - Handlers live in an insertion-ordered registry (a ``dict`` keyed by the
21
+ handler — Python's ordered-``dict`` stands in for the TS insertion-ordered
22
+ ``Set``), so registration order is the delivery order and duplicate handler
23
+ identities collapse to one slot.
24
+ - :meth:`SignalHub.subscribe` returns an unsubscribe thunk; calling it (even
25
+ during a dispatch, even more than once) is safe and idempotent.
26
+ - :meth:`SignalHub.emit` fans out over a *snapshot* of the handler registry,
27
+ so a handler that subscribes or unsubscribes mid-dispatch never corrupts
28
+ the in-progress iteration — the change takes effect on the next emit.
29
+ - A throwing handler is isolated: its error is routed to the optional
30
+ ``on_handler_error`` sink and the remaining handlers still run. One bad
31
+ consumer never silences the others.
32
+
33
+ Translator design (ported from TS ``src/conductor/signal-hub/translate.ts``):
34
+
35
+ - The mapping is keyed on the framework event's ``type`` discriminant, so it
36
+ is expressed as a ``dict[str, Callable]`` — one entry per framework event
37
+ tag. TS enforced exhaustiveness with a mapped type at compile time; the
38
+ Python port enforces it with a key-coverage test asserting the table's
39
+ keys equal the tags of every ``AgentEvent`` union member (exhaustiveness
40
+ moved compiler → test).
41
+ - Each rule is a pure, side-effect-free function over a single event.
42
+ - ``MessageUpdateEvent.assistantMessageEvent`` is a **best-effort dict** in
43
+ the Python framework (a small mapping such as
44
+ ``{"type": "text_delta", "delta": ...}``), so the inner streaming dispatch
45
+ reads keys defensively — and every other payload probe accepts both
46
+ mapping- and attribute-shaped values, since the live agent passes frozen
47
+ dataclass messages while tests may pass plain dicts.
48
+
49
+ Mapping rules (framework ``type`` → product signals):
50
+
51
+ - ``agent_start`` / ``turn_start`` / ``message_start`` / ``agent_end``
52
+ → ``[]`` (loop bookkeeping; not product-visible)
53
+ - ``message_end`` → one ``prompt`` for a *user* message (the turn
54
+ is now committed), one ``fault`` for an errored *assistant* message,
55
+ else ``[]``
56
+ - ``tool_execution_start`` → one ``tool_start`` (id, name)
57
+ - ``tool_execution_update`` → ``[]`` (partial tool progress not surfaced)
58
+ - ``tool_execution_end`` → one ``tool_end`` (id, ok = not isError)
59
+ - ``turn_end`` → one ``turn_end`` (usage)
60
+ - ``message_update`` → delegated to the inner streaming dispatch on
61
+ ``assistantMessageEvent["type"]``:
62
+ ``text_delta`` → ``text``; ``thinking_delta`` → ``thinking``;
63
+ ``error`` → ``fault`` (model); everything else → ``[]``
64
+
65
+ Faults: a streaming ``error`` sub-event becomes a typed ``model`` fault. The
66
+ conductor layers its own ``aborted`` / ``tool`` / ``persistence`` /
67
+ ``overflow`` faults elsewhere; the translator only mints the one fault the
68
+ framework stream actually carries.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ from collections.abc import Callable, Mapping, Sequence
74
+ from typing import Any
75
+
76
+ from indusagi.agent import AgentEvent
77
+ from indusagi.ai import Usage, create_zero_usage
78
+
79
+ from .contract import (
80
+ FaultSignal,
81
+ PromptSignal,
82
+ SessionSignal,
83
+ SignalHandler,
84
+ TextSignal,
85
+ ThinkingSignal,
86
+ ToolEndSignal,
87
+ ToolStartSignal,
88
+ TurnEndSignal,
89
+ conductor_fault,
90
+ )
91
+
92
+ __all__ = [
93
+ "SignalHub",
94
+ "TRANSLATOR_TABLE",
95
+ "translate_agent_event",
96
+ ]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # SignalHub — the synchronous fan-out bus
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class SignalHub:
105
+ """A synchronous fan-out bus for :data:`SessionSignal` values.
106
+
107
+ Construct one per session; subscribe consumers; emit signals. Not a
108
+ framework type and not wired to one — purely the conductor's own event
109
+ surface.
110
+
111
+ :param on_handler_error: invoked when a subscribed handler raises during
112
+ :meth:`emit` — lets the owner observe/log faults in consumer code
113
+ without aborting the fan-out. If omitted, handler errors are
114
+ swallowed so a misbehaving consumer cannot break signal delivery to
115
+ the others. This sink is itself guarded — if it raises, the raise is
116
+ ignored. (The TS ``SignalHubOptions`` bag collapses to this single
117
+ keyword argument.)
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ *,
123
+ on_handler_error: Callable[[Exception, SessionSignal], None] | None = None,
124
+ ) -> None:
125
+ # Insertion-ordered live registry of subscribed handlers. A dict
126
+ # keyed by the handler (values unused) — the Python stand-in for the
127
+ # TS insertion-ordered Set: registration order is delivery order and
128
+ # duplicate identities collapse to one slot.
129
+ self._handlers: dict[SignalHandler, None] = {}
130
+ self._on_handler_error = on_handler_error
131
+
132
+ def subscribe(self, handler: SignalHandler) -> Callable[[], None]:
133
+ """Register a handler to receive every subsequently emitted signal.
134
+
135
+ Registering the same function reference twice is a no-op (the
136
+ registry collapses it). The returned thunk removes the handler; it is
137
+ idempotent and safe to call during a dispatch.
138
+
139
+ :param handler: the consumer callback
140
+ :returns: an unsubscribe function
141
+ """
142
+ self._handlers[handler] = None
143
+ active = True
144
+
145
+ def unsubscribe() -> None:
146
+ nonlocal active
147
+ if not active:
148
+ return
149
+ active = False
150
+ self._handlers.pop(handler, None)
151
+
152
+ return unsubscribe
153
+
154
+ def emit(self, signal: SessionSignal) -> None:
155
+ """Fan a signal out to every currently-subscribed handler, in
156
+ registration order.
157
+
158
+ Iterates a snapshot of the handler registry, so subscribe/unsubscribe
159
+ performed by a handler mid-dispatch affects only later emits. A
160
+ throwing handler is isolated via the ``on_handler_error`` sink; the
161
+ remaining handlers still run.
162
+
163
+ :param signal: the signal to deliver
164
+ """
165
+ # Snapshot so concurrent (re-entrant) (un)subscribes don't disturb
166
+ # this pass.
167
+ for handler in list(self._handlers):
168
+ try:
169
+ handler(signal)
170
+ except Exception as error: # noqa: BLE001 — isolation is the point
171
+ self._report(error, signal)
172
+
173
+ @property
174
+ def size(self) -> int:
175
+ """Number of currently-subscribed handlers."""
176
+ return len(self._handlers)
177
+
178
+ def clear(self) -> None:
179
+ """Remove every handler. Subsequent :meth:`emit` calls deliver to no
180
+ one."""
181
+ self._handlers.clear()
182
+
183
+ def _report(self, error: Exception, signal: SessionSignal) -> None:
184
+ """Route a handler error to the sink, guarding the sink itself."""
185
+ if self._on_handler_error is None:
186
+ return
187
+ try:
188
+ self._on_handler_error(error, signal)
189
+ except Exception: # noqa: BLE001
190
+ # A throwing error-sink must not break the fan-out.
191
+ pass
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # translate_agent_event — the framework-event → SessionSignal projector
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def _field(obj: Any, name: str, default: Any = None) -> Any:
200
+ """Defensive payload probe: read ``name`` off a mapping key or an
201
+ attribute, whichever the value carries.
202
+
203
+ The framework's events are frozen dataclasses with camelCase fields, but
204
+ their ``message`` / ``assistantMessageEvent`` payloads are best-effort
205
+ (dict-shaped in the shim, dataclass-shaped from the live loop) — and the
206
+ TS translator probed structurally for the same reason. Never raises on an
207
+ unexpected payload.
208
+ """
209
+ if isinstance(obj, Mapping):
210
+ return obj.get(name, default)
211
+ return getattr(obj, name, default)
212
+
213
+
214
+ def _usage_of(message: Any) -> Usage:
215
+ """Pull the cumulative :class:`Usage` off a settled turn's message.
216
+
217
+ ``turn_end.message`` is an ``AgentMessage`` (a union); only an assistant
218
+ message carries ``usage``. We structurally probe for it rather than
219
+ importing a guard, and fall back to a zero-usage value so the
220
+ ``turn_end`` signal always carries a well-formed ``Usage``.
221
+ """
222
+ usage = _field(message, "usage")
223
+ if usage is not None:
224
+ return usage
225
+ return create_zero_usage()
226
+
227
+
228
+ def _prompt_text_of(content: Any) -> str:
229
+ """Best-effort plain text of a user message's content.
230
+
231
+ A user ``AgentMessage`` carries its content either as a bare string or as
232
+ a sequence of content blocks; we want the readable text either way.
233
+ Blocks without a string ``text`` field (e.g. images) contribute nothing.
234
+ Structurally probed rather than imported so the translator stays
235
+ decoupled from the message shape, and never raises on an unexpected
236
+ payload.
237
+ """
238
+ if isinstance(content, str):
239
+ return content
240
+ if isinstance(content, Sequence):
241
+ parts: list[str] = []
242
+ for block in content:
243
+ text = _field(block, "text")
244
+ parts.append(text if isinstance(text, str) else "")
245
+ return "".join(parts)
246
+ return ""
247
+
248
+
249
+ def _fault_message_of(error: Any) -> str:
250
+ """Best-effort human-readable summary for a streaming-error payload."""
251
+ message = _field(error, "errorMessage")
252
+ if isinstance(message, str):
253
+ return message
254
+ return "model stream error"
255
+
256
+
257
+ def _translate_stream_event(sub: Any) -> list[SessionSignal]:
258
+ """Inner dispatch for ``message_update``: map the streaming
259
+ ``AssistantMessageEvent`` sub-event to a product signal.
260
+
261
+ Keyed on the sub-event's own ``type`` discriminant — read defensively,
262
+ because the Python framework forwards it as a best-effort **dict** (e.g.
263
+ ``{"type": "text_delta", "delta": ...}``). Only the three
264
+ product-visible sub-events (``text_delta``, ``thinking_delta``,
265
+ ``error``) produce a signal; the lifecycle/partial sub-events
266
+ (``*_start``, ``*_end``, ``toolcall_*``, ``done``) are loop-internal and
267
+ map to ``[]``.
268
+ """
269
+ sub_type = _field(sub, "type")
270
+ if sub_type == "text_delta":
271
+ return [TextSignal(delta=_field(sub, "delta"))]
272
+ if sub_type == "thinking_delta":
273
+ return [ThinkingSignal(delta=_field(sub, "delta"))]
274
+ if sub_type == "error":
275
+ error = _field(sub, "error")
276
+ return [
277
+ FaultSignal(fault=conductor_fault("model", _fault_message_of(error), error))
278
+ ]
279
+ return []
280
+
281
+
282
+ def _none(_event: Any) -> list[SessionSignal]:
283
+ """No product signal for this framework event."""
284
+ return []
285
+
286
+
287
+ def _message_end(event: Any) -> list[SessionSignal]:
288
+ # Two settled-message cases are product-visible:
289
+ # 1. A user message_end means the just-submitted prompt has entered the
290
+ # conversation. Surface it as a `prompt` signal so a UI can echo the
291
+ # user turn the instant it is accepted — before the model's first
292
+ # token — and not appear to "hang" until the reply streams. (The
293
+ # agent appends the user message to its state on this same event, so
294
+ # by the time consumers react the message is already readable from
295
+ # `messages()`.)
296
+ # 2. An assistant message_end carries the failure when a call errored
297
+ # (e.g. a 404 for a retired model): surface it as a fault so the
298
+ # turn is not silently empty. The successful text itself streams via
299
+ # `message_update`/`text_delta`, not here.
300
+ message = _field(event, "message")
301
+ role = _field(message, "role")
302
+ if role == "user":
303
+ return [PromptSignal(text=_prompt_text_of(_field(message, "content")))]
304
+ error_message = _field(message, "errorMessage")
305
+ if role == "assistant" and (
306
+ _field(message, "stopReason") == "error" or isinstance(error_message, str)
307
+ ):
308
+ return [
309
+ FaultSignal(
310
+ fault=conductor_fault(
311
+ "model",
312
+ error_message
313
+ if isinstance(error_message, str)
314
+ else "the model call failed",
315
+ )
316
+ )
317
+ ]
318
+ return []
319
+
320
+
321
+ def _turn_end(event: Any) -> list[SessionSignal]:
322
+ # Settled turn: surface cumulative usage.
323
+ return [TurnEndSignal(usage=_usage_of(_field(event, "message")))]
324
+
325
+
326
+ def _message_update(event: Any) -> list[SessionSignal]:
327
+ # Streaming deltas + stream error: delegate to the inner dispatch.
328
+ return _translate_stream_event(_field(event, "assistantMessageEvent"))
329
+
330
+
331
+ def _tool_execution_start(event: Any) -> list[SessionSignal]:
332
+ return [ToolStartSignal(id=_field(event, "toolCallId"), name=_field(event, "toolName"))]
333
+
334
+
335
+ def _tool_execution_end(event: Any) -> list[SessionSignal]:
336
+ return [ToolEndSignal(id=_field(event, "toolCallId"), ok=not _field(event, "isError"))]
337
+
338
+
339
+ #: The frozen mapping from each framework event tag to its product-signal
340
+ #: rule — exactly one rule per ``AgentEvent`` union member.
341
+ #:
342
+ #: TS declared this as a full mapped type so the compiler rejected the module
343
+ #: until every framework event variant had a rule; the Python port asserts
344
+ #: the same exhaustiveness with a key-coverage test
345
+ #: (``tests/conductor/test_contract_hub_skill.py``) comparing these keys to
346
+ #: the ``type`` tags of every ``AgentEvent`` union member.
347
+ #:
348
+ #: Exported for testing/inspection; production code calls
349
+ #: :func:`translate_agent_event`, which is the table applied.
350
+ TRANSLATOR_TABLE: dict[str, Callable[[Any], list[SessionSignal]]] = {
351
+ # --- Lifecycle / turn / message bookkeeping: not product-visible -------
352
+ "agent_start": _none,
353
+ "agent_end": _none,
354
+ "turn_start": _none,
355
+ "message_start": _none,
356
+ "message_end": _message_end,
357
+ # --- Settled turn: surface cumulative usage -----------------------------
358
+ "turn_end": _turn_end,
359
+ # --- Streaming deltas + stream error: delegate to the inner dispatch ----
360
+ "message_update": _message_update,
361
+ # --- Tool lifecycle ------------------------------------------------------
362
+ "tool_execution_start": _tool_execution_start,
363
+ "tool_execution_update": _none,
364
+ "tool_execution_end": _tool_execution_end,
365
+ }
366
+
367
+
368
+ def translate_agent_event(event: AgentEvent) -> list[SessionSignal]:
369
+ """Translate a single framework ``AgentEvent`` into the product-level
370
+ :data:`SessionSignal` values it produces (zero, one, or — for future
371
+ fan-out — many).
372
+
373
+ Pure and total over the known event union: every framework event variant
374
+ has a rule (asserted by the key-coverage test), so this never raises on a
375
+ known event and returns ``[]`` for events that carry no product meaning.
376
+
377
+ :param event: a framework agent loop event
378
+ :returns: the ordered product signals derived from it
379
+ :raises KeyError: on an event tag outside the framework union (the TS
380
+ table was compiler-total; the Python port fails loud)
381
+ """
382
+ return TRANSLATOR_TABLE[_field(event, "type")](event)
@@ -0,0 +1,294 @@
1
+ """Skill-invocation block parser — an attribute-scanning hand parser.
2
+
3
+ The coding agent lets a turn carry a *skill invocation*: an XML-ish opener
4
+ tag that names a capability card to run, optionally locates its on-disk
5
+ file, and wraps a trailing user message (the body) the skill should act on.
6
+ The shape is the framework Agent-Skills convention::
7
+
8
+ <skill name="commit-helper" location="/abs/path/SKILL.md">
9
+ please tidy the staged diff and write a message
10
+ </skill>
11
+
12
+ This module re-derives that parse from the *format spec*, deliberately NOT
13
+ as one monolithic capture regex. It is a small hand-written scanner: it
14
+ walks the input, finds the opener, scans ``key=value`` attributes one at a
15
+ time (handling single- or double-quoted and bare values, with
16
+ ``&amp;``/``&lt;``/``&gt;``/``&quot;``/``&#39;`` entity decoding), then
17
+ captures the body up to the matching close tag. Reading attributes
18
+ incrementally — rather than matching the whole block in a single pattern —
19
+ is what makes this an independent implementation and lets the parser keep
20
+ *every* attribute the author wrote, not just a fixed ``name``/``location``
21
+ pair.
22
+
23
+ Block grammar (informal)::
24
+
25
+ block := WS? '<' TAG WS attrs? WS? '>' body '</' TAG WS? '>'
26
+ TAG := 'skill' (case-insensitive)
27
+ attrs := (attr WS?)*
28
+ attr := key ('=' value)?
29
+ key := [A-Za-z_][A-Za-z0-9_:.-]*
30
+ value := '"' ... '"' | '\\'' ... '\\'' | bare (bare stops at WS / '>')
31
+ body := any text, captured verbatim up to the close tag
32
+
33
+ A valid block must (a) be the leading content of the text (only leading
34
+ whitespace may precede the opener), (b) name the ``skill`` tag, (c) carry a
35
+ non-empty ``name`` attribute, and (d) be closed by ``</skill>``. Anything
36
+ else yields ``None``, signalling "this turn is ordinary text, not a skill
37
+ call".
38
+
39
+ Port note (TS ``src/conductor/skill-parse/parse.ts``): the index-walking
40
+ scanner ports verbatim, with one mechanical difference — TS ``text[i]`` past
41
+ the end is ``undefined`` while Python raises, so every cursor read carries
42
+ an explicit bounds guard where TS leaned on ``undefined``.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import re
48
+ from collections.abc import Mapping
49
+ from dataclasses import dataclass
50
+ from types import MappingProxyType
51
+
52
+ __all__ = [
53
+ "SkillInvocation",
54
+ "parse_skill_invocation",
55
+ ]
56
+
57
+
58
+ # The recognised opener/closer tag name (compared case-insensitively).
59
+ _SKILL_TAG = "skill"
60
+
61
+ # The attribute whose value is promoted to the result's ``name`` field.
62
+ _NAME_ATTR = "name"
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class SkillInvocation:
67
+ """The structured result of a successful parse.
68
+
69
+ - ``name`` — the value of the required ``name`` attribute (the skill to
70
+ run).
71
+ - ``args`` — every attribute the opener carried, keyed by (lower-cased)
72
+ name, including ``name`` itself and any ``location``/custom keys. A
73
+ bare attribute with no ``=value`` maps to the empty string.
74
+ - ``body`` — the verbatim text between the opener and the ``</skill>``
75
+ close, with a single leading and trailing newline trimmed (the common
76
+ case where the opener/closer sit on their own lines) but inner
77
+ whitespace preserved.
78
+ """
79
+
80
+ name: str
81
+ args: Mapping[str, str]
82
+ body: str
83
+
84
+
85
+ def _is_space(ch: str) -> bool:
86
+ """True for ASCII whitespace the scanner skips between tokens."""
87
+ return ch in (" ", "\t", "\n", "\r", "\f", "\v")
88
+
89
+
90
+ def _is_key_start(ch: str) -> bool:
91
+ """True for the first character of an attribute key."""
92
+ return ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ch == "_"
93
+
94
+
95
+ def _is_key_char(ch: str) -> bool:
96
+ """True for a continuing character of an attribute key."""
97
+ return _is_key_start(ch) or ("0" <= ch <= "9") or ch in ("-", ":", ".")
98
+
99
+
100
+ def _skip_space(text: str, i: int) -> int:
101
+ """Advance past any run of whitespace from ``i``; returns the new index."""
102
+ j = i
103
+ while j < len(text) and _is_space(text[j]):
104
+ j += 1
105
+ return j
106
+
107
+
108
+ _ENTITY_RE = re.compile(r"&(#39|#x27|#34|#x22|amp|lt|gt|quot|apos);", re.IGNORECASE)
109
+
110
+
111
+ def _decode_entities(raw: str) -> str:
112
+ """Decode the small set of XML/HTML entities an author might write inside
113
+ an attribute value or the body. Only the canonical five (plus the two
114
+ common numeric apostrophe/quote forms) are recognised; everything else is
115
+ left as written so arbitrary text passes through untouched."""
116
+ if "&" not in raw:
117
+ return raw
118
+
119
+ def repl(match: re.Match[str]) -> str:
120
+ code = match.group(1).lower()
121
+ if code == "amp":
122
+ return "&"
123
+ if code == "lt":
124
+ return "<"
125
+ if code == "gt":
126
+ return ">"
127
+ if code in ("quot", "#34", "#x22"):
128
+ return '"'
129
+ if code in ("apos", "#39", "#x27"):
130
+ return "'"
131
+ return match.group(0)
132
+
133
+ return _ENTITY_RE.sub(repl, raw)
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class _AttrScan:
138
+ """The cursor result of scanning a single attribute."""
139
+
140
+ key: str
141
+ value: str
142
+ next: int
143
+
144
+
145
+ def _scan_attribute(text: str, i: int) -> _AttrScan | None:
146
+ """Scan one ``key`` / ``key="value"`` / ``key='value'`` / ``key=bare``
147
+ attribute starting at ``i`` (which must sit on a key-start character).
148
+ Returns the decoded key/value and the index just past the attribute, or
149
+ ``None`` if the key is malformed."""
150
+ j = i
151
+ key_start = j
152
+ if not _is_key_start(text[j]):
153
+ return None
154
+ j += 1
155
+ while j < len(text) and _is_key_char(text[j]):
156
+ j += 1
157
+ key = text[key_start:j].lower()
158
+
159
+ # Look for an `=` (whitespace may sit on either side of it).
160
+ after_key = _skip_space(text, j)
161
+ if after_key >= len(text) or text[after_key] != "=":
162
+ # Bare attribute: present, no value.
163
+ return _AttrScan(key=key, value="", next=j)
164
+
165
+ v = _skip_space(text, after_key + 1)
166
+ quote = text[v] if v < len(text) else None
167
+ if quote in ('"', "'"):
168
+ val_start = v + 1
169
+ k = val_start
170
+ while k < len(text) and text[k] != quote:
171
+ k += 1
172
+ if k >= len(text):
173
+ # Unterminated quote — treat the remainder as the value (best effort).
174
+ return _AttrScan(key=key, value=_decode_entities(text[val_start:]), next=len(text))
175
+ return _AttrScan(key=key, value=_decode_entities(text[val_start:k]), next=k + 1)
176
+
177
+ # Bare value: read until whitespace or the tag terminator.
178
+ val_start = v
179
+ while v < len(text) and not _is_space(text[v]) and text[v] not in (">", "/"):
180
+ v += 1
181
+ return _AttrScan(key=key, value=_decode_entities(text[val_start:v]), next=v)
182
+
183
+
184
+ def _trim_body_edges(body: str) -> str:
185
+ """Strip one leading and one trailing newline (with optional CR) from the
186
+ body."""
187
+ s = body
188
+ if s.startswith("\r\n"):
189
+ s = s[2:]
190
+ elif s.startswith("\n") or s.startswith("\r"):
191
+ s = s[1:]
192
+ if s.endswith("\r\n"):
193
+ s = s[:-2]
194
+ elif s.endswith("\n") or s.endswith("\r"):
195
+ s = s[:-1]
196
+ return s
197
+
198
+
199
+ def parse_skill_invocation(text: str) -> SkillInvocation | None:
200
+ """Parse a skill-invocation block out of ``text``.
201
+
202
+ Walks the string by hand: skips leading whitespace, confirms a
203
+ ``<skill …>`` opener, scans each attribute into an ``args`` record,
204
+ captures the body up to the matching ``</skill>``, and decodes entities.
205
+ Returns the structured invocation, or ``None`` when the text is not a
206
+ well-formed leading skill block (ordinary turns simply parse to
207
+ ``None``).
208
+
209
+ :param text: the raw turn text that may begin with a skill block
210
+ :returns: the parsed :class:`SkillInvocation`, or ``None`` if not a
211
+ skill block
212
+ """
213
+ i = _skip_space(text, 0)
214
+
215
+ # 1) Opener: '<' then the tag name (case-insensitive), bounded by a delimiter.
216
+ if i >= len(text) or text[i] != "<":
217
+ return None
218
+ i += 1
219
+ tag_start = i
220
+ while i < len(text) and _is_key_char(text[i]):
221
+ i += 1
222
+ tag = text[tag_start:i].lower()
223
+ if tag != _SKILL_TAG:
224
+ return None
225
+ # The tag must be followed by whitespace or the tag terminator, not more name.
226
+ after_tag = text[i] if i < len(text) else None
227
+ if after_tag is not None and not _is_space(after_tag) and after_tag not in (">", "/"):
228
+ return None
229
+
230
+ # 2) Attributes: scan `key[=value]` pairs until the opener's '>'.
231
+ args: dict[str, str] = {}
232
+ while True:
233
+ i = _skip_space(text, i)
234
+ if i >= len(text):
235
+ return None # opener never closed
236
+ ch = text[i]
237
+ if ch == ">":
238
+ i += 1
239
+ break
240
+ if ch == "/":
241
+ # A self-closing `<skill .../>` carries no body; reject (a skill
242
+ # call needs one).
243
+ return None
244
+ if not _is_key_start(ch):
245
+ return None # junk where an attribute or '>' was expected
246
+ scan = _scan_attribute(text, i)
247
+ if scan is None:
248
+ return None
249
+ if scan.key not in args:
250
+ args[scan.key] = scan.value # first writer wins per key
251
+ i = scan.next
252
+
253
+ # 3) A skill call is identified by a non-empty `name` attribute.
254
+ name = args.get(_NAME_ATTR)
255
+ if name is None or len(name) == 0:
256
+ return None
257
+
258
+ # 4) Body: capture verbatim up to the matching close tag `</skill>`.
259
+ body_start = i
260
+ close = _find_close_tag(text, i)
261
+ if close is None:
262
+ return None
263
+ body = _trim_body_edges(_decode_entities(text[body_start:close]))
264
+
265
+ return SkillInvocation(name=name, args=MappingProxyType(args), body=body)
266
+
267
+
268
+ def _find_close_tag(text: str, i: int) -> int | None:
269
+ """From ``i``, find the index of the ``<`` that begins the matching
270
+ ``</skill>`` close tag (case-insensitive, tolerant of whitespace before
271
+ the ``>``). Returns the index of that ``<``, or ``None`` if no close tag
272
+ is found."""
273
+ j = i
274
+ while j < len(text):
275
+ lt = text.find("<", j)
276
+ if lt == -1:
277
+ return None
278
+ k = lt + 1
279
+ if k >= len(text) or text[k] != "/":
280
+ j = lt + 1
281
+ continue
282
+ k = _skip_space(text, k + 1)
283
+ name_start = k
284
+ while k < len(text) and _is_key_char(text[k]):
285
+ k += 1
286
+ close_tag = text[name_start:k].lower()
287
+ if close_tag != _SKILL_TAG:
288
+ j = lt + 1
289
+ continue
290
+ k = _skip_space(text, k)
291
+ if k < len(text) and text[k] == ">":
292
+ return lt
293
+ j = lt + 1
294
+ return None