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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""The single :class:`~induscode.runtime_bridge.contract.BridgeEventSink`
|
|
2
|
+
implementation (port of TS ``src/runtime-bridge/sink.ts``) — the one place
|
|
3
|
+
the imperative push-stream idiom every bridge shares is written down.
|
|
4
|
+
|
|
5
|
+
Each external runtime emits its own wire dialect; a bridge's parser maps
|
|
6
|
+
that dialect onto the provider-neutral
|
|
7
|
+
:data:`~induscode.runtime_bridge.contract.NormalizedEvent` union and drives a
|
|
8
|
+
sink. This module owns everything else:
|
|
9
|
+
|
|
10
|
+
- the accumulating ``AssistantMessage`` every framework event carries as its
|
|
11
|
+
``partial``;
|
|
12
|
+
- the content-block index bookkeeping (a block's index is its position in
|
|
13
|
+
the content sequence, opened lazily on first emission of its kind);
|
|
14
|
+
- the lazily-started lifecycle (``start`` is implicit on first emission, and
|
|
15
|
+
idempotent) and the terminal ``finish_success`` / ``finish_error`` settle;
|
|
16
|
+
- the ``NormalizedEvent -> framework event`` mapping, in :meth:`BridgeSink.emit`,
|
|
17
|
+
so the per-bridge code stays a pure parser.
|
|
18
|
+
|
|
19
|
+
Frozen-message adaptation (the port's one structural change — PLAN M2 /
|
|
20
|
+
analysis 01 risk-1)
|
|
21
|
+
--------------------------------------------------------------------------
|
|
22
|
+
The TS sink mutates one shared ``partial`` ``AssistantMessage`` in place
|
|
23
|
+
(``block.text += delta``) and hands the *same object* to every push call.
|
|
24
|
+
The Python framework's ``AssistantMessage`` is a **frozen** dataclass whose
|
|
25
|
+
``content`` is a **tuple** of frozen parts — in-place mutation is
|
|
26
|
+
impossible. So this port keeps a **mutable builder** (a plain list of
|
|
27
|
+
``["text", buffer]`` / ``["thinking", buffer]`` / ``["toolCall", ToolCall]``
|
|
28
|
+
parts whose string buffers accumulate across deltas) and **materializes a
|
|
29
|
+
fresh frozen ``AssistantMessage`` snapshot per push** — the exact idiom the
|
|
30
|
+
framework's own facade stream uses (``indusagi.ai.stream._Accumulator``:
|
|
31
|
+
"Snapshots materialize a fresh frozen ``AssistantMessage`` per event …
|
|
32
|
+
consumers only ever read it at delivery time, which snapshots reproduce
|
|
33
|
+
exactly"). A block's index is its position in the builder list, so the
|
|
34
|
+
content-index bookkeeping is unchanged from TS.
|
|
35
|
+
|
|
36
|
+
The framework push surface this writes through — ``push_start /
|
|
37
|
+
push_text_start / push_text_delta / push_text_end / push_thinking_start /
|
|
38
|
+
push_thinking_delta / push_thinking_end / push_tool_call_start /
|
|
39
|
+
push_tool_call_delta / push_tool_call_end / push_done / push_error`` on
|
|
40
|
+
``AssistantMessageEventStream`` (verified against
|
|
41
|
+
``indusagi/ai/events.py``) — is consumed verbatim from the framework via
|
|
42
|
+
:func:`indusagi.ai.create_assistant_message_event_stream`; this module never
|
|
43
|
+
re-derives event shapes.
|
|
44
|
+
|
|
45
|
+
Streaming contract honored (matching the framework's own adapters): a text
|
|
46
|
+
or thinking block opens with a ``*_start`` once the block has been appended
|
|
47
|
+
to the builder, streams ``*_delta``\\ s while the running content
|
|
48
|
+
accumulates, and closes with a ``*_end`` carrying the final content. A tool
|
|
49
|
+
call is a single self-contained block (``start -> delta(args) -> end``). The
|
|
50
|
+
terminal ``done`` / ``error`` event carries the fully-accumulated message.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
import time
|
|
57
|
+
from collections.abc import Callable
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
from typing import Any, Final, Literal
|
|
60
|
+
|
|
61
|
+
# The push stream is constructed through the framework's factory value; the
|
|
62
|
+
# message/content types come from the same barrel.
|
|
63
|
+
from indusagi.ai import (
|
|
64
|
+
AssistantMessage,
|
|
65
|
+
AssistantMessageEventStream,
|
|
66
|
+
TextContent,
|
|
67
|
+
ThinkingContent,
|
|
68
|
+
ToolCall,
|
|
69
|
+
create_assistant_message_event_stream,
|
|
70
|
+
create_zero_usage,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
from induscode.runtime_bridge.contract import (
|
|
74
|
+
NORMALIZED_EVENT_KINDS,
|
|
75
|
+
BridgeEventSink,
|
|
76
|
+
BridgeFailure,
|
|
77
|
+
FinishReason,
|
|
78
|
+
NormalizedEvent,
|
|
79
|
+
NormalizedEventKind,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"BridgeMessageSeed",
|
|
84
|
+
"create_bridge_sink",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Message identity seed
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class BridgeMessageSeed:
|
|
95
|
+
"""The model-identity fields stamped onto every ``partial``
|
|
96
|
+
``AssistantMessage`` the sink emits (TS ``BridgeMessageSeed``). A bridge
|
|
97
|
+
supplies these from the bound model so a runtime-backed turn reports the
|
|
98
|
+
same ``api`` / ``provider`` / ``model`` triple a network turn would —
|
|
99
|
+
keeping the accumulated message uniform regardless of which path
|
|
100
|
+
produced it."""
|
|
101
|
+
|
|
102
|
+
#: The bound model's ``api`` dialect identifier.
|
|
103
|
+
api: str
|
|
104
|
+
#: The bound model's provider slug.
|
|
105
|
+
provider: str
|
|
106
|
+
#: The bound model's id (the value stamped into ``AssistantMessage.model``).
|
|
107
|
+
model: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Internal: open-block tracking
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# The currently-open streamable content block: ("text" | "thinking", index).
|
|
115
|
+
# Text and thinking blocks stay open across their deltas so a later delta of
|
|
116
|
+
# a *different* kind first closes the prior block (emitting its `*_end`)
|
|
117
|
+
# before opening its own — mirroring the framework's one-block-at-a-time
|
|
118
|
+
# content stream. A tool call never lingers as the open block (it opens and
|
|
119
|
+
# closes atomically), so it is not tracked here.
|
|
120
|
+
_OpenBlock = tuple[Literal["text", "thinking"], int]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _now_ms() -> int:
|
|
124
|
+
"""Milliseconds since the Unix epoch (TS ``Date.now()``)."""
|
|
125
|
+
return int(time.time() * 1000)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Sink implementation
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class BridgeSink:
|
|
134
|
+
"""The concrete :class:`BridgeEventSink`. Private to the module; bridges
|
|
135
|
+
construct one through :func:`create_bridge_sink`."""
|
|
136
|
+
|
|
137
|
+
__slots__ = (
|
|
138
|
+
"_error_message",
|
|
139
|
+
"_open",
|
|
140
|
+
"_parts",
|
|
141
|
+
"_seed",
|
|
142
|
+
"_settled",
|
|
143
|
+
"_started",
|
|
144
|
+
"_stop_reason",
|
|
145
|
+
"_stream",
|
|
146
|
+
"_timestamp",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __init__(self, seed: BridgeMessageSeed) -> None:
|
|
150
|
+
self._stream: AssistantMessageEventStream = create_assistant_message_event_stream()
|
|
151
|
+
self._seed = seed
|
|
152
|
+
# The MUTABLE builder behind the frozen snapshots. Each part:
|
|
153
|
+
# ["text", buffer] | ["thinking", buffer] | ["toolCall", ToolCall]
|
|
154
|
+
# A block's index is its position in this list (== its contentIndex).
|
|
155
|
+
self._parts: list[list[Any]] = []
|
|
156
|
+
#: The currently-open streamable block, if any.
|
|
157
|
+
self._open: _OpenBlock | None = None
|
|
158
|
+
#: Whether :meth:`start` has pushed the framework ``start`` event.
|
|
159
|
+
self._started = False
|
|
160
|
+
#: Whether a terminal ``done`` / ``error`` has been pushed.
|
|
161
|
+
self._settled = False
|
|
162
|
+
# Snapshot fields the TS sink mutated on the shared partial.
|
|
163
|
+
self._stop_reason: str = "stop"
|
|
164
|
+
self._error_message: str | None = None
|
|
165
|
+
# One message identity per exchange, like the TS partial's timestamp.
|
|
166
|
+
self._timestamp = _now_ms()
|
|
167
|
+
|
|
168
|
+
# ---- snapshot construction ----
|
|
169
|
+
|
|
170
|
+
def _snapshot(self) -> AssistantMessage:
|
|
171
|
+
"""Materialize a fresh frozen ``AssistantMessage`` from the mutable
|
|
172
|
+
builder — the per-push ``partial``. External runtimes do not report
|
|
173
|
+
framework token accounting on the streamed event surface (a CLI owns
|
|
174
|
+
its own metering), so the message carries a zeroed usage rather than
|
|
175
|
+
fabricated numbers."""
|
|
176
|
+
content: list[TextContent | ThinkingContent | ToolCall] = []
|
|
177
|
+
for part in self._parts:
|
|
178
|
+
if part[0] == "text":
|
|
179
|
+
content.append(TextContent(text=part[1]))
|
|
180
|
+
elif part[0] == "thinking":
|
|
181
|
+
content.append(ThinkingContent(thinking=part[1]))
|
|
182
|
+
else: # ["toolCall", ToolCall] — already frozen, reused as-is.
|
|
183
|
+
content.append(part[1])
|
|
184
|
+
return AssistantMessage(
|
|
185
|
+
content=tuple(content),
|
|
186
|
+
api=self._seed.api,
|
|
187
|
+
provider=self._seed.provider,
|
|
188
|
+
model=self._seed.model,
|
|
189
|
+
usage=create_zero_usage(),
|
|
190
|
+
stopReason=self._stop_reason, # type: ignore[arg-type]
|
|
191
|
+
errorMessage=self._error_message,
|
|
192
|
+
timestamp=self._timestamp,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ---- lifecycle ----
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def stream(self) -> AssistantMessageEventStream:
|
|
199
|
+
"""The framework push stream handed back to the broker's caller."""
|
|
200
|
+
return self._stream
|
|
201
|
+
|
|
202
|
+
def start(self) -> None:
|
|
203
|
+
if self._started or self._settled:
|
|
204
|
+
return
|
|
205
|
+
self._started = True
|
|
206
|
+
self._stream.push_start(self._snapshot())
|
|
207
|
+
|
|
208
|
+
# ---- text ----
|
|
209
|
+
|
|
210
|
+
def text(self, delta: str) -> None:
|
|
211
|
+
if self._settled:
|
|
212
|
+
return
|
|
213
|
+
self.start()
|
|
214
|
+
index = self._ensure_open("text")
|
|
215
|
+
self._parts[index][1] += delta
|
|
216
|
+
self._stream.push_text_delta(index, delta, self._snapshot())
|
|
217
|
+
|
|
218
|
+
# ---- thinking ----
|
|
219
|
+
|
|
220
|
+
def thinking(self, delta: str) -> None:
|
|
221
|
+
if self._settled:
|
|
222
|
+
return
|
|
223
|
+
self.start()
|
|
224
|
+
index = self._ensure_open("thinking")
|
|
225
|
+
self._parts[index][1] += delta
|
|
226
|
+
self._stream.push_thinking_delta(index, delta, self._snapshot())
|
|
227
|
+
|
|
228
|
+
# ---- tool call ----
|
|
229
|
+
|
|
230
|
+
def tool_call(self, call: ToolCall) -> None:
|
|
231
|
+
if self._settled:
|
|
232
|
+
return
|
|
233
|
+
self.start()
|
|
234
|
+
# A tool call is a self-contained block: close any open text/thinking
|
|
235
|
+
# block first so content ordering stays linear, then open/stream/close
|
|
236
|
+
# atomically.
|
|
237
|
+
self._close_open()
|
|
238
|
+
index = len(self._parts)
|
|
239
|
+
block = ToolCall(
|
|
240
|
+
id=call.id,
|
|
241
|
+
name=call.name,
|
|
242
|
+
arguments=call.arguments,
|
|
243
|
+
thoughtSignature=call.thoughtSignature,
|
|
244
|
+
)
|
|
245
|
+
self._parts.append(["toolCall", block])
|
|
246
|
+
self._stream.push_tool_call_start(index, self._snapshot())
|
|
247
|
+
self._stream.push_tool_call_delta(
|
|
248
|
+
index,
|
|
249
|
+
json.dumps(dict(block.arguments), separators=(",", ":")),
|
|
250
|
+
self._snapshot(),
|
|
251
|
+
)
|
|
252
|
+
self._stream.push_tool_call_end(index, block, self._snapshot())
|
|
253
|
+
|
|
254
|
+
# ---- normalized-event dispatch ----
|
|
255
|
+
|
|
256
|
+
def emit(self, event: NormalizedEvent) -> None:
|
|
257
|
+
_EMIT_DISPATCH[event.kind](self, event)
|
|
258
|
+
|
|
259
|
+
# ---- terminal: success ----
|
|
260
|
+
|
|
261
|
+
def finish_success(self, reason: FinishReason = "stop") -> None:
|
|
262
|
+
if self._settled:
|
|
263
|
+
return
|
|
264
|
+
self.start()
|
|
265
|
+
self._close_open()
|
|
266
|
+
self._settled = True
|
|
267
|
+
self._stop_reason = reason
|
|
268
|
+
self._stream.push_done(reason, self._snapshot())
|
|
269
|
+
|
|
270
|
+
# ---- terminal: error ----
|
|
271
|
+
|
|
272
|
+
def finish_error(self, error: BridgeFailure) -> None:
|
|
273
|
+
if self._settled:
|
|
274
|
+
return
|
|
275
|
+
self.start()
|
|
276
|
+
self._close_open()
|
|
277
|
+
self._settled = True
|
|
278
|
+
reason: Literal["aborted", "error"] = "aborted" if error.aborted else "error"
|
|
279
|
+
self._stop_reason = reason
|
|
280
|
+
self._error_message = error.message
|
|
281
|
+
self._stream.push_error(reason, self._snapshot())
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Internal block management
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _ensure_open(self, kind: Literal["text", "thinking"]) -> int:
|
|
288
|
+
"""Ensure the open block is of ``kind``, returning its content index.
|
|
289
|
+
If a block of a different kind is open it is closed first; if none is
|
|
290
|
+
open (or the kind differs), a fresh block is appended and its
|
|
291
|
+
``*_start`` is pushed."""
|
|
292
|
+
if self._open is not None:
|
|
293
|
+
if self._open[0] == kind:
|
|
294
|
+
return self._open[1]
|
|
295
|
+
self._close_open()
|
|
296
|
+
index = len(self._parts)
|
|
297
|
+
if kind == "text":
|
|
298
|
+
self._parts.append(["text", ""])
|
|
299
|
+
self._stream.push_text_start(index, self._snapshot())
|
|
300
|
+
else:
|
|
301
|
+
self._parts.append(["thinking", ""])
|
|
302
|
+
self._stream.push_thinking_start(index, self._snapshot())
|
|
303
|
+
self._open = (kind, index)
|
|
304
|
+
return index
|
|
305
|
+
|
|
306
|
+
def _close_open(self) -> None:
|
|
307
|
+
"""Close the currently-open text/thinking block, pushing its
|
|
308
|
+
``*_end`` with the accumulated content. No-op when nothing is open."""
|
|
309
|
+
open_ = self._open
|
|
310
|
+
if open_ is None:
|
|
311
|
+
return
|
|
312
|
+
self._open = None
|
|
313
|
+
kind, index = open_
|
|
314
|
+
if kind == "text":
|
|
315
|
+
self._stream.push_text_end(index, self._parts[index][1], self._snapshot())
|
|
316
|
+
else:
|
|
317
|
+
self._stream.push_thinking_end(index, self._parts[index][1], self._snapshot())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# The NormalizedEvent -> sink-method dispatch (the TS exhaustive `switch`).
|
|
321
|
+
# Keys are pinned against NORMALIZED_EVENT_KINDS by a key-coverage test
|
|
322
|
+
# (PLAN rule 1: exhaustiveness via tests, not types). A `resume` event is
|
|
323
|
+
# informational: the bridge persists the resume token out-of-band; the event
|
|
324
|
+
# stream carries no resume signal, so it is a no-op here.
|
|
325
|
+
_EMIT_DISPATCH: Final[dict[NormalizedEventKind, Callable[[BridgeSink, Any], None]]] = {
|
|
326
|
+
"text": lambda sink, event: sink.text(event.delta),
|
|
327
|
+
"thinking": lambda sink, event: sink.thinking(event.delta),
|
|
328
|
+
"tool_call": lambda sink, event: sink.tool_call(event.call),
|
|
329
|
+
"resume": lambda sink, event: None,
|
|
330
|
+
"finish": lambda sink, event: sink.finish_success(event.reason),
|
|
331
|
+
"failed": lambda sink, event: sink.finish_error(event.error),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
assert set(_EMIT_DISPATCH) == set(NORMALIZED_EVENT_KINDS)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Factory
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def create_bridge_sink(seed: BridgeMessageSeed) -> BridgeEventSink:
|
|
343
|
+
"""Construct a :class:`BridgeEventSink` seeded with the bound model's
|
|
344
|
+
identity (TS ``createBridgeSink``). The single sanctioned way a bridge
|
|
345
|
+
obtains a sink: it creates one, drives it with normalized events (or the
|
|
346
|
+
convenience emitters), and returns :attr:`BridgeEventSink.stream`. The
|
|
347
|
+
imperative push idiom lives entirely inside the returned sink.
|
|
348
|
+
|
|
349
|
+
:param seed: the bound model's ``api`` / ``provider`` / ``model`` identity
|
|
350
|
+
"""
|
|
351
|
+
return BridgeSink(seed)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Sessions subsystem — public barrel (port of TS ``src/sessions/index.ts``).
|
|
2
|
+
|
|
3
|
+
Exposes the :class:`SessionLibrary` (the catalog-and-navigation layer over
|
|
4
|
+
the conductor's persisted transcripts) and its value contract — the
|
|
5
|
+
:class:`SavedSession` catalog row, the :class:`BranchNode` navigator row, and
|
|
6
|
+
the :class:`PriorTurn` fork candidate. Consumers import the session-library
|
|
7
|
+
surface from ``induscode.sessions`` rather than reaching into the individual
|
|
8
|
+
modules.
|
|
9
|
+
|
|
10
|
+
(The per-cwd ``--<cwd slug>--`` scope-dir helper is **not** here — it is
|
|
11
|
+
boot's, ported with ``boot/runners/session.py`` in the M4 launch/boot wave;
|
|
12
|
+
the library takes the already-scoped directory.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from induscode.sessions.contract import BranchNode, PriorTurn, SavedSession
|
|
18
|
+
from induscode.sessions.library import SessionLibrary
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BranchNode",
|
|
22
|
+
"PriorTurn",
|
|
23
|
+
"SavedSession",
|
|
24
|
+
"SessionLibrary",
|
|
25
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Session Library contract — the value types the library surface speaks in
|
|
2
|
+
(port of TS ``src/sessions/contract.ts``).
|
|
3
|
+
|
|
4
|
+
The library sits on top of the conductor's persistent transcript store: it
|
|
5
|
+
enumerates the session files a workspace has accumulated, opens one back into
|
|
6
|
+
a live :class:`~induscode.conductor.transcript_store.TranscriptStore`, and
|
|
7
|
+
projects the resulting node tree into the flat shapes a navigator and a
|
|
8
|
+
forking picker consume.
|
|
9
|
+
|
|
10
|
+
These records are the *catalog/navigation* projections — the minimum a
|
|
11
|
+
chooser UI needs. They deliberately hold no framework message objects: text
|
|
12
|
+
is already reduced to a short preview, and identity is carried by stable
|
|
13
|
+
string ids so a caller can list, rename, delete, walk, and fork without
|
|
14
|
+
rehydrating payloads.
|
|
15
|
+
|
|
16
|
+
Field names keep the TS camelCase spelling (``lastModified``,
|
|
17
|
+
``messageCount``, ``entryId``, ``isLeaf``, ``isCurrent``) per the port
|
|
18
|
+
convention.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BranchNode",
|
|
27
|
+
"PriorTurn",
|
|
28
|
+
"SavedSession",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Saved-session catalog record
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class SavedSession:
|
|
39
|
+
"""One persisted session as the library catalogs it — what a chooser
|
|
40
|
+
shows in a list before anything is opened.
|
|
41
|
+
|
|
42
|
+
``id`` is the bare session identifier (the filename stem the conductor's
|
|
43
|
+
filesystem backend writes to); ``path`` is its absolute on-disk location.
|
|
44
|
+
The remaining fields are best-effort enrichments filled when the library
|
|
45
|
+
is asked to peek inside a file: a derived ``name``, file
|
|
46
|
+
``lastModified``/``size``, the ``messageCount`` of conversational nodes,
|
|
47
|
+
and a short ``preview`` of the opening user turn. Any of them may be
|
|
48
|
+
``None`` when only a shallow listing was taken.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
#: Bare session identifier — the filename stem, no extension or directory.
|
|
52
|
+
id: str
|
|
53
|
+
#: Absolute on-disk path the session persists to.
|
|
54
|
+
path: str
|
|
55
|
+
#: Human-facing label for the session, when one could be derived.
|
|
56
|
+
name: str | None = None
|
|
57
|
+
#: Last-modified wall-clock time of the file, in epoch milliseconds.
|
|
58
|
+
lastModified: float | None = None
|
|
59
|
+
#: Byte size of the session file on disk.
|
|
60
|
+
size: int | None = None
|
|
61
|
+
#: Count of conversational (user/assistant/tool) nodes in the transcript.
|
|
62
|
+
messageCount: int | None = None
|
|
63
|
+
#: Short single-line excerpt of the opening turn, for at-a-glance
|
|
64
|
+
#: recognition.
|
|
65
|
+
preview: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Transcript-tree navigation node
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class BranchNode:
|
|
75
|
+
"""One row in the flattened view of a loaded transcript tree.
|
|
76
|
+
|
|
77
|
+
The conductor stores a branchable tree of nodes; a navigator wants a
|
|
78
|
+
single ordered list it can render and select against. Each
|
|
79
|
+
:class:`BranchNode` names its ``id``, its ``parent`` link (so a caller
|
|
80
|
+
can still reconstruct the tree), a render-ready ``label``, the
|
|
81
|
+
indentation ``depth`` from the root, and two flags: ``isLeaf`` for a tip
|
|
82
|
+
of the tree and ``isCurrent`` for the node the active head points at.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
#: Stable id of the underlying transcript node.
|
|
86
|
+
id: str
|
|
87
|
+
#: Parent node id, or ``None`` for a transcript root.
|
|
88
|
+
parent: str | None
|
|
89
|
+
#: Render-ready, depth-indented one-line label.
|
|
90
|
+
label: str
|
|
91
|
+
#: Distance from the root (a root is depth 0).
|
|
92
|
+
depth: int
|
|
93
|
+
#: True when this node has no children — a tip of the tree.
|
|
94
|
+
isLeaf: bool
|
|
95
|
+
#: True when the active head currently points at this node.
|
|
96
|
+
isCurrent: bool
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Prior-turn fork candidate
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class PriorTurn:
|
|
106
|
+
"""A past user turn offered as a fork point.
|
|
107
|
+
|
|
108
|
+
To re-ask or re-edit an earlier prompt, a caller picks one of these and
|
|
109
|
+
the conductor branches the transcript at ``entryId``. ``text`` is the
|
|
110
|
+
full prompt text; ``preview`` is the trimmed single-line form a picker
|
|
111
|
+
renders.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
#: Id of the transcript node this user turn lives on (the fork target).
|
|
115
|
+
entryId: str
|
|
116
|
+
#: Full text of the user prompt.
|
|
117
|
+
text: str
|
|
118
|
+
#: Trimmed single-line excerpt of ``text``, for display.
|
|
119
|
+
preview: str
|