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,449 @@
1
+ """TranscriptStore — the conductor's persistent, branchable transcript
2
+ (port of TS ``src/conductor/transcript-store/store.ts``).
3
+
4
+ The transcript is an append-only **tree** of
5
+ :class:`~induscode.conductor.contract.TranscriptEntry` nodes: every node names
6
+ its ``parent``, a single :class:`~induscode.conductor.contract.SessionHead`
7
+ tracks the active ``leaf``, and the active conversation is the ``parent``-chain
8
+ walked from that leaf back to a root.
9
+
10
+ Design stance (an independent persistence model):
11
+
12
+ - **Reducer-friendly.** The on-disk form is a flat NDJSON log; the in-memory
13
+ view is rebuilt by a pure :func:`replay` reducer over that log. State is a
14
+ value derived from the log, never mutated in place behind the scenes.
15
+ - **Fresh envelope.** Lines carry the ``indus/transcript@1`` schema with our
16
+ own field names (``prev``, ``at``, ``kind``), delegated to
17
+ :mod:`induscode.conductor.serialize` — not any framework-internal
18
+ session-manager schema.
19
+ - **Branch = move the head.** :meth:`TranscriptStore.branch_at` repoints the
20
+ leaf at an earlier node; the next :meth:`TranscriptStore.append` becomes
21
+ that node's child, forking a new path without rewriting history.
22
+ - **``path_to`` is the read primitive.** It resolves the root→leaf branch
23
+ (the message list the agent replays); the conductor's ``resume`` calls it
24
+ to rehydrate.
25
+
26
+ The store is storage-pluggable: a :class:`TranscriptBackend` abstracts where
27
+ bytes live, so tests inject an in-memory backend and the conductor binds a
28
+ filesystem one. Construction is synchronous and empty;
29
+ :meth:`TranscriptStore.load` hydrates from a backend.
30
+
31
+ Port note: the TS plain-object backends/clocks become frozen dataclasses of
32
+ callables — the same "bag of functions" shape, test-injectable field by field.
33
+ Disk I/O runs through :func:`asyncio.to_thread` so the async backend surface
34
+ never blocks the loop.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ from collections.abc import Awaitable, Callable, Mapping
41
+ from dataclasses import dataclass
42
+ from datetime import datetime, timezone
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from ulid import ULID
47
+
48
+ from induscode.conductor.contract import (
49
+ TRANSCRIPT_SCHEMA,
50
+ SessionHead,
51
+ TranscriptEntry,
52
+ TranscriptRole,
53
+ )
54
+ from induscode.conductor.serialize import (
55
+ encode_entry,
56
+ encode_head,
57
+ parse_session_text,
58
+ role_for_message,
59
+ )
60
+
61
+ __all__ = [
62
+ "TRANSCRIPT_SCHEMA",
63
+ "TranscriptBackend",
64
+ "TranscriptClock",
65
+ "TranscriptState",
66
+ "TranscriptStore",
67
+ "fs_backend",
68
+ "memory_backend",
69
+ "replay",
70
+ ]
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Storage backend
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class TranscriptBackend:
80
+ """Where a transcript's NDJSON log lives (TS ``TranscriptBackend``).
81
+
82
+ The store appends lines and, on branch, rewrites the whole file; a backend
83
+ supplies those three primitives plus a way to resolve a session id to its
84
+ location. The default :func:`fs_backend` maps a session id to
85
+ ``<dir>/<sessionId>.ndjson``; tests pass :func:`memory_backend` so no disk
86
+ is touched.
87
+ """
88
+
89
+ # Absolute location (path/key) a session id persists to.
90
+ locate: Callable[[str], str]
91
+ # Read the full NDJSON text for a session, or None if absent.
92
+ read: Callable[[str], Awaitable[str | None]]
93
+ # Append one already-encoded NDJSON line (no trailing newline) to a session.
94
+ append: Callable[[str, str], Awaitable[None]]
95
+ # Replace the whole NDJSON text for a session (used after a branch).
96
+ rewrite: Callable[[str, str], Awaitable[None]]
97
+
98
+
99
+ def memory_backend() -> TranscriptBackend:
100
+ """An in-memory backend — the default when no persistence is wired."""
101
+ files: dict[str, list[str]] = {}
102
+
103
+ async def read(session_id: str) -> str | None:
104
+ lines = files.get(session_id)
105
+ return "\n".join(lines) if lines is not None else None
106
+
107
+ async def append(session_id: str, line: str) -> None:
108
+ files.setdefault(session_id, []).append(line)
109
+
110
+ async def rewrite(session_id: str, text: str) -> None:
111
+ files[session_id] = [] if len(text) == 0 else text.split("\n")
112
+
113
+ return TranscriptBackend(
114
+ locate=lambda session_id: f"mem:{session_id}",
115
+ read=read,
116
+ append=append,
117
+ rewrite=rewrite,
118
+ )
119
+
120
+
121
+ def fs_backend(dir: str) -> TranscriptBackend:
122
+ """A filesystem backend rooted at ``dir``."""
123
+ root = Path(dir)
124
+
125
+ def _file_of(session_id: str) -> Path:
126
+ return root / f"{session_id}.ndjson"
127
+
128
+ async def read(session_id: str) -> str | None:
129
+ def _read() -> str | None:
130
+ try:
131
+ return _file_of(session_id).read_text(encoding="utf-8")
132
+ except OSError:
133
+ return None
134
+
135
+ return await asyncio.to_thread(_read)
136
+
137
+ async def append(session_id: str, line: str) -> None:
138
+ def _append() -> None:
139
+ root.mkdir(parents=True, exist_ok=True)
140
+ with open(_file_of(session_id), "a", encoding="utf-8") as fh:
141
+ fh.write(f"{line}\n")
142
+
143
+ await asyncio.to_thread(_append)
144
+
145
+ async def rewrite(session_id: str, text: str) -> None:
146
+ def _rewrite() -> None:
147
+ root.mkdir(parents=True, exist_ok=True)
148
+ body = "" if len(text) == 0 else f"{text}\n"
149
+ _file_of(session_id).write_text(body, encoding="utf-8")
150
+
151
+ await asyncio.to_thread(_rewrite)
152
+
153
+ return TranscriptBackend(
154
+ locate=lambda session_id: f"{dir}/{session_id}.ndjson",
155
+ read=read,
156
+ append=append,
157
+ rewrite=rewrite,
158
+ )
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Reducer-friendly in-memory shape
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class TranscriptState:
168
+ """The immutable derived state of a transcript: the node table keyed by
169
+ id, plus the current head. A pure value produced by :func:`replay`; the
170
+ store holds one and swaps it wholesale on each mutation rather than
171
+ mutating fields (TS ``TranscriptState``)."""
172
+
173
+ # Every node, keyed by id, in insertion order. Treated as read-only.
174
+ nodes: Mapping[str, TranscriptEntry]
175
+ # The active head (session id + current leaf).
176
+ head: SessionHead
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class TranscriptClock:
181
+ """A clock + id seam so tests get deterministic ids/timestamps
182
+ (TS ``TranscriptClock``)."""
183
+
184
+ # Mint a fresh, sortable node id.
185
+ id: Callable[[], str]
186
+ # The current instant as an ISO-8601 string.
187
+ now: Callable[[], str]
188
+
189
+
190
+ def _iso_now() -> str:
191
+ """``new Date().toISOString()`` — millisecond precision, ``Z`` suffix."""
192
+ now = datetime.now(timezone.utc)
193
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
194
+
195
+
196
+ #: The live clock — ULIDs and wall-clock ISO timestamps.
197
+ _LIVE_CLOCK = TranscriptClock(id=lambda: str(ULID()), now=_iso_now)
198
+
199
+
200
+ def replay(
201
+ session_id: str,
202
+ entries: list[TranscriptEntry] | tuple[TranscriptEntry, ...],
203
+ leaf: str | None,
204
+ ) -> TranscriptState:
205
+ """Rebuild a :class:`TranscriptState` from an ordered entry list and a leaf.
206
+
207
+ Pure and total: the same inputs always yield the same state. The reducer
208
+ just indexes the entries by id (later ids overwrite earlier — append-only
209
+ logs never collide, but a rewrite stays idempotent) and pins the head.
210
+ """
211
+ nodes: dict[str, TranscriptEntry] = {}
212
+ for entry in entries:
213
+ nodes[entry.id] = entry
214
+ return TranscriptState(nodes=nodes, head=SessionHead(sessionId=session_id, leaf=leaf))
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # TranscriptStore
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ class TranscriptStore:
223
+ """An append-only, branchable transcript bound to one session id.
224
+
225
+ Construct (empty), then either :meth:`append` fresh nodes or :meth:`load`
226
+ a persisted session. Every mutation appends to the backend and swaps the
227
+ derived :class:`TranscriptState`. Reads (:meth:`path_to`,
228
+ :meth:`messages`, :attr:`head`) are O(branch depth) walks over the
229
+ in-memory node table.
230
+ """
231
+
232
+ __slots__ = ("_state", "_backend", "_clock")
233
+
234
+ def __init__(
235
+ self,
236
+ session_id: str,
237
+ *,
238
+ backend: TranscriptBackend | None = None,
239
+ clock: TranscriptClock | None = None,
240
+ ) -> None:
241
+ self._backend = backend if backend is not None else memory_backend()
242
+ self._clock = clock if clock is not None else _LIVE_CLOCK
243
+ self._state = replay(session_id, [], None)
244
+
245
+ @property
246
+ def session_id(self) -> str:
247
+ """The session id this store is bound to."""
248
+ return self._state.head.sessionId
249
+
250
+ @property
251
+ def head(self) -> SessionHead:
252
+ """The current head (session id + active leaf)."""
253
+ return self._state.head
254
+
255
+ @property
256
+ def size(self) -> int:
257
+ """Number of nodes currently in the transcript."""
258
+ return len(self._state.nodes)
259
+
260
+ def locate(self, session_id: str | None = None) -> str:
261
+ """Backend location this session persists to (for diagnostics/UI)."""
262
+ return self._backend.locate(session_id if session_id is not None else self.session_id)
263
+
264
+ def get(self, id: str) -> TranscriptEntry | None:
265
+ """Look up a single node by id."""
266
+ return self._state.nodes.get(id)
267
+
268
+ async def append(
269
+ self,
270
+ content: Any,
271
+ role: TranscriptRole | None = None,
272
+ meta: Mapping[str, Any] | None = None,
273
+ ) -> TranscriptEntry:
274
+ """Append a framework ``AgentMessage`` as a child of the current leaf
275
+ and advance the head onto it. The node role is derived from the
276
+ message (or overridden via ``role``). Persists the new line, then
277
+ returns the new node.
278
+
279
+ This is the conductor's per-message write: one call per produced
280
+ message in a settled turn.
281
+ """
282
+ entry = TranscriptEntry(
283
+ id=self._clock.id(),
284
+ parent=self._state.head.leaf,
285
+ role=role if role is not None else role_for_message(content),
286
+ content=content,
287
+ createdAt=self._clock.now(),
288
+ meta=dict(meta) if meta is not None else None,
289
+ )
290
+ await self._backend.append(self.session_id, encode_entry(entry))
291
+ self._commit(entry)
292
+ return entry
293
+
294
+ async def branch_at(self, id: str) -> None:
295
+ """Repoint the head at an earlier node, forking a branch. The next
296
+ :meth:`append` becomes a child of ``id``; existing nodes are
297
+ untouched. Rewrites the head line.
298
+
299
+ Raises ``ValueError`` when ``id`` is not a known node.
300
+ """
301
+ if id not in self._state.nodes:
302
+ raise ValueError(f'TranscriptStore.branch_at: unknown node "{id}"')
303
+ self._state = TranscriptState(
304
+ nodes=self._state.nodes,
305
+ head=SessionHead(sessionId=self.session_id, leaf=id),
306
+ )
307
+ await self._flush()
308
+
309
+ async def reset(self) -> None:
310
+ """Reset the head to before any node; the next :meth:`append` starts a
311
+ new root. Used when re-editing the very first turn."""
312
+ self._state = TranscriptState(
313
+ nodes=self._state.nodes,
314
+ head=SessionHead(sessionId=self.session_id, leaf=None),
315
+ )
316
+ await self._flush()
317
+
318
+ def start_new_session(self, session_id: str) -> None:
319
+ """Abandon the current conversation and begin a fresh, empty session
320
+ under a new id. All nodes are dropped and the leaf is reset to
321
+ ``None``, so :meth:`messages` is empty and the next :meth:`append`
322
+ starts a new root; subsequent writes persist to the new session's
323
+ backing file (the old one is left intact on disk). This is the
324
+ ``/clear`` (new-session) primitive — distinct from :meth:`reset`,
325
+ which keeps the nodes and id and only rewinds the head."""
326
+ self._state = replay(session_id, [], None)
327
+
328
+ def path_to(self, from_id: str | None = None) -> list[TranscriptEntry]:
329
+ """Resolve the active branch: the root→leaf node list reached by
330
+ walking ``parent`` links up from ``leaf`` (or from ``from_id`` when
331
+ given), reversed to chronological order.
332
+
333
+ This is the conductor's read primitive — ``resume`` rehydrates the
334
+ agent from ``path_to()``, and the turn loop projects it to messages
335
+ via :meth:`messages`. Returns ``[]`` for an empty transcript or a
336
+ dangling leaf.
337
+ """
338
+ start_id = from_id if from_id is not None else self._state.head.leaf
339
+ chain: list[TranscriptEntry] = []
340
+ cursor: str | None = start_id
341
+ seen: set[str] = set()
342
+ while cursor is not None:
343
+ if cursor in seen:
344
+ break # defensive: never loop on a corrupt parent link
345
+ seen.add(cursor)
346
+ node = self._state.nodes.get(cursor)
347
+ if node is None:
348
+ break
349
+ chain.append(node)
350
+ cursor = node.parent
351
+ chain.reverse()
352
+ return chain
353
+
354
+ def messages(self, from_id: str | None = None) -> list[Any]:
355
+ """The active branch projected to the framework ``AgentMessage`` list."""
356
+ return [entry.content for entry in self.path_to(from_id)]
357
+
358
+ async def load(self, session_id: str | None = None) -> bool:
359
+ """Hydrate this store from a persisted session, replacing its current
360
+ state.
361
+
362
+ Reads the backend, parses it with
363
+ :func:`~induscode.conductor.serialize.parse_session_text`, and
364
+ rebuilds state via the :func:`replay` reducer. If the file carried no
365
+ head line, the head is pinned to the deepest leaf reachable from the
366
+ entries. Returns ``True`` when a session was found and loaded,
367
+ ``False`` when the backend had nothing.
368
+ """
369
+ sid = session_id if session_id is not None else self.session_id
370
+ text = await self._backend.read(sid)
371
+ if text is None:
372
+ return False
373
+ parsed = parse_session_text(sid, text)
374
+ leaf = parsed.head.leaf if parsed.head is not None else _deepest_leaf(parsed.entries)
375
+ self._state = replay(sid, parsed.entries, leaf)
376
+ return True
377
+
378
+ @classmethod
379
+ async def open(
380
+ cls,
381
+ session_id: str,
382
+ *,
383
+ backend: TranscriptBackend | None = None,
384
+ clock: TranscriptClock | None = None,
385
+ ) -> "TranscriptStore | None":
386
+ """Open a persisted session by id, returning a hydrated store (or
387
+ ``None`` if the backend has no such session). The static counterpart
388
+ to :meth:`load`."""
389
+ store = cls(session_id, backend=backend, clock=clock)
390
+ found = await store.load(session_id)
391
+ return store if found else None
392
+
393
+ def state(self) -> TranscriptState:
394
+ """A point-in-time copy of the derived state (for inspection/testing)."""
395
+ return self._state
396
+
397
+ # -- internals ----------------------------------------------------------
398
+
399
+ def _commit(self, entry: TranscriptEntry) -> None:
400
+ """Fold a freshly appended node into state and advance the leaf onto it."""
401
+ nodes = dict(self._state.nodes)
402
+ nodes[entry.id] = entry
403
+ self._state = TranscriptState(
404
+ nodes=nodes,
405
+ head=SessionHead(sessionId=self.session_id, leaf=entry.id),
406
+ )
407
+
408
+ async def _flush(self) -> None:
409
+ """Rewrite the whole backend file (head line first, then every node line)."""
410
+ lines = [encode_head(self._state.head)]
411
+ for entry in self._state.nodes.values():
412
+ lines.append(encode_entry(entry))
413
+ await self._backend.rewrite(self.session_id, "\n".join(lines))
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Helpers
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ def _deepest_leaf(entries: list[TranscriptEntry]) -> str | None:
422
+ """Pick the deepest leaf when a loaded file lacked an explicit head line:
423
+ the node with the longest ``parent`` chain (ties broken by latest id).
424
+ Best-effort recovery for hand-edited or legacy-imported transcripts."""
425
+ if len(entries) == 0:
426
+ return None
427
+ by_id = {entry.id: entry for entry in entries}
428
+
429
+ def depth_of(id: str) -> int:
430
+ depth = 0
431
+ cursor: str | None = id
432
+ seen: set[str] = set()
433
+ while cursor is not None and cursor not in seen:
434
+ seen.add(cursor)
435
+ node = by_id.get(cursor)
436
+ if node is None:
437
+ break
438
+ depth += 1
439
+ cursor = node.parent
440
+ return depth
441
+
442
+ best = entries[0]
443
+ best_depth = depth_of(best.id)
444
+ for entry in entries:
445
+ depth = depth_of(entry.id)
446
+ if depth > best_depth or (depth == best_depth and entry.id > best.id):
447
+ best = entry
448
+ best_depth = depth
449
+ return best.id
@@ -0,0 +1,236 @@
1
+ """Interactive console package (M5) — public barrel.
2
+
3
+ Port of TS ``src/console`` (``index.ts``, growing wave by wave). The console
4
+ is the *product shell* wrapped around the framework's rendering library: the
5
+ framework (``indusagi.react_ink``) supplies the message list, streaming
6
+ markdown, footer/status strips, and all dialog bodies; the console adds
7
+ everything that makes those a coding-agent app.
8
+
9
+ Landed waves:
10
+
11
+ - **The contract** (:mod:`.contract`) — the frozen type surface: the
12
+ :class:`ConsoleState` slice (rows/blocks/modal/status/scheme/toggles/busy;
13
+ the composer buffer/caret/history live in the framework editor), the
14
+ :data:`ConsoleEvent` union (TS ``domain:verb`` tags verbatim), the 12-kind
15
+ :data:`ModalKind` machine, the theme vocabulary, and the
16
+ :class:`ConsoleProps` / :class:`OverlayServices` host shapes. The M1 slash
17
+ framework types (:mod:`induscode.console_slash`) are re-exported here so
18
+ console consumers import one surface.
19
+ - **The reducer** (:mod:`.reducer`) — the single pure fold over
20
+ :class:`ConsoleState`.
21
+ - **The theme engine** (:mod:`.theme`) — four own-authored 9-stop ramps →
22
+ 25 semantic tokens → the framework colour-key projection → one
23
+ ``ThemeBundle`` per scheme (painter adapter + Textual Theme + Pygments
24
+ style), assembled once into :data:`THEMES` and resolved through
25
+ :func:`resolve_theme`.
26
+ - **Startup gathering** (:mod:`.startup`) — the pure resource/changelog
27
+ survey the banner draws.
28
+ - :mod:`.slash_commands` — the transcript and workbench slash command
29
+ groups, written against the M1 :mod:`induscode.console_slash` framework.
30
+ - :mod:`.input` — the console input layer: the console-intent vocabulary and
31
+ the chord→intent table the ``ConsoleApp`` BINDINGS derive from, the
32
+ double-tap chord latch + Ctrl+C exit-window helpers, and the slash/
33
+ ``@``-path autocomplete providers feeding the framework ``PromptEditor``
34
+ (TS ``src/console/input``; ``paste.ts`` collapsed into the framework
35
+ ``EditorCore`` paste markers).
36
+ - :mod:`.components` — the chrome widgets (``Banner`` / ``Emblem`` /
37
+ ``StatusBar``); :mod:`.overlays` — the awaited ``push_screen_wait`` flows
38
+ behind :func:`~induscode.console.overlays.open_overlay`.
39
+ - **The surface** (:mod:`.app`) — :class:`ConsoleApp`, the Textual rewrite
40
+ of TS ``TerminalConsole.tsx``: the conductor signal→reducer projection,
41
+ the redesigned streaming-segment bookkeeping (with
42
+ ``stream_parity_report``), submit routing (``!``/``!!`` → slash → prompt),
43
+ the INTENT_TABLE-derived BINDINGS + chord/exit-window machines, the
44
+ overlay workers, and the exit transcript.
45
+ - **The mount** (:mod:`.mount`) — :func:`mount_console`, the single entry
46
+ point a run mode awaits to take over the terminal; the boot repl runner's
47
+ default ``set_console_mount`` seam resolves to it.
48
+ """
49
+
50
+ from induscode.console_slash import (
51
+ Handled,
52
+ OpenModal,
53
+ Prompt,
54
+ SlashCommand,
55
+ SlashContext,
56
+ SlashOutcome,
57
+ SlashRegistry,
58
+ SlashRun,
59
+ Unknown,
60
+ )
61
+
62
+ from .app import (
63
+ ConductorSignalMessage,
64
+ ConsoleApp,
65
+ count_providers,
66
+ project_snapshot,
67
+ read_branch,
68
+ read_double_escape_action,
69
+ )
70
+ from .contract import (
71
+ CONSOLE_EVENT_TYPES,
72
+ DEFAULT_SCHEME,
73
+ EMPTY_CONSOLE_STATE,
74
+ MODAL_KINDS,
75
+ NO_MODAL,
76
+ THEME_TOKEN_ROLES,
77
+ BlockAppend,
78
+ BlocksClear,
79
+ BusySet,
80
+ ConductorState,
81
+ ConsoleDispatch,
82
+ ConsoleEvent,
83
+ ConsoleEventType,
84
+ ConsoleHost,
85
+ ConsoleProps,
86
+ ConsoleReducer,
87
+ ConsoleState,
88
+ ConsoleTheme,
89
+ InkThemeAdapter,
90
+ ModalClose,
91
+ ModalKind,
92
+ ModalOpen,
93
+ ModalState,
94
+ OverlayServices,
95
+ RowsAppend,
96
+ RowsPatch,
97
+ RowsSet,
98
+ SchemeSet,
99
+ SessionConductor,
100
+ SessionSignal,
101
+ SessionSnapshot,
102
+ StartOAuthLogin,
103
+ StatusClear,
104
+ StatusMessage,
105
+ StatusSet,
106
+ ThemePalette,
107
+ ThemeScheme,
108
+ ThemeToken,
109
+ ThemeTokens,
110
+ Tick,
111
+ ToggleImages,
112
+ ToggleReasoning,
113
+ ToolExecutionState,
114
+ UiDisplayBlock,
115
+ ViewRow,
116
+ ViewRowKind,
117
+ is_theme_scheme,
118
+ transition_modal,
119
+ )
120
+ from .mount import mount_console
121
+ from .reducer import console_reducer, init_console_state
122
+ from .startup import (
123
+ StartupChangelog,
124
+ StartupChangelogMode,
125
+ StartupInputs,
126
+ StartupMap,
127
+ StartupNotice,
128
+ StartupNoticeKind,
129
+ StartupSection,
130
+ gather_changelog,
131
+ gather_startup,
132
+ )
133
+ from .theme import (
134
+ DAYLIGHT_CB_PALETTE,
135
+ DAYLIGHT_PALETTE,
136
+ MIDNIGHT_CB_PALETTE,
137
+ MIDNIGHT_PALETTE,
138
+ PALETTES,
139
+ THEME_SCHEMES,
140
+ THEMES,
141
+ derive_tokens,
142
+ framework_colors,
143
+ resolve_theme,
144
+ theme_adapter,
145
+ theme_bundle,
146
+ )
147
+
148
+ __all__ = [
149
+ "BlockAppend",
150
+ "BlocksClear",
151
+ "BusySet",
152
+ "CONSOLE_EVENT_TYPES",
153
+ "ConductorSignalMessage",
154
+ "ConductorState",
155
+ "ConsoleApp",
156
+ "ConsoleDispatch",
157
+ "ConsoleEvent",
158
+ "ConsoleEventType",
159
+ "ConsoleHost",
160
+ "ConsoleProps",
161
+ "ConsoleReducer",
162
+ "ConsoleState",
163
+ "ConsoleTheme",
164
+ "DAYLIGHT_CB_PALETTE",
165
+ "DAYLIGHT_PALETTE",
166
+ "DEFAULT_SCHEME",
167
+ "EMPTY_CONSOLE_STATE",
168
+ "Handled",
169
+ "InkThemeAdapter",
170
+ "MIDNIGHT_CB_PALETTE",
171
+ "MIDNIGHT_PALETTE",
172
+ "MODAL_KINDS",
173
+ "ModalClose",
174
+ "ModalKind",
175
+ "ModalOpen",
176
+ "ModalState",
177
+ "NO_MODAL",
178
+ "OpenModal",
179
+ "OverlayServices",
180
+ "PALETTES",
181
+ "Prompt",
182
+ "RowsAppend",
183
+ "RowsPatch",
184
+ "RowsSet",
185
+ "SchemeSet",
186
+ "SessionConductor",
187
+ "SessionSignal",
188
+ "SessionSnapshot",
189
+ "SlashCommand",
190
+ "SlashContext",
191
+ "SlashOutcome",
192
+ "SlashRegistry",
193
+ "SlashRun",
194
+ "StartOAuthLogin",
195
+ "StartupChangelog",
196
+ "StartupChangelogMode",
197
+ "StartupInputs",
198
+ "StartupMap",
199
+ "StartupNotice",
200
+ "StartupNoticeKind",
201
+ "StartupSection",
202
+ "StatusClear",
203
+ "StatusMessage",
204
+ "StatusSet",
205
+ "THEMES",
206
+ "THEME_SCHEMES",
207
+ "THEME_TOKEN_ROLES",
208
+ "ThemePalette",
209
+ "ThemeScheme",
210
+ "ThemeToken",
211
+ "ThemeTokens",
212
+ "Tick",
213
+ "ToggleImages",
214
+ "ToggleReasoning",
215
+ "ToolExecutionState",
216
+ "UiDisplayBlock",
217
+ "Unknown",
218
+ "ViewRow",
219
+ "ViewRowKind",
220
+ "console_reducer",
221
+ "count_providers",
222
+ "derive_tokens",
223
+ "framework_colors",
224
+ "gather_changelog",
225
+ "gather_startup",
226
+ "init_console_state",
227
+ "is_theme_scheme",
228
+ "mount_console",
229
+ "project_snapshot",
230
+ "read_branch",
231
+ "read_double_escape_action",
232
+ "resolve_theme",
233
+ "theme_adapter",
234
+ "theme_bundle",
235
+ "transition_modal",
236
+ ]