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,575 @@
1
+ """Transcript serialization — the bridge between the conductor's on-disk
2
+ ``TranscriptEntry`` vocabulary and the framework's ``AgentMessage`` shapes
3
+ (port of TS ``src/conductor/transcript-store/serialize.ts``).
4
+
5
+ Two distinct directions live here, and the split is deliberate:
6
+
7
+ 1. **Conductor ⇄ conductor** — encoding a ``TranscriptEntry`` to/from a single
8
+ NDJSON line in the ``indus/transcript@1`` format. This is the conductor's
9
+ *own* envelope (``schema``/``id``/``prev``/``role``/``message``/``at``/
10
+ ``meta``). The store owns these field names; they are our own, not any
11
+ framework-internal session schema.
12
+
13
+ 2. **Conductor ⇄ framework** — projecting a transcript branch down to the
14
+ ``AgentMessage`` list the agent loop consumes, and (for migration) lifting
15
+ a legacy framework session file *up* into transcript entries. Here we
16
+ **delegate the actual message resolution to the framework**: the
17
+ framework's published loader/context helpers
18
+ (:func:`indusagi.agent.load_entries_from_file`,
19
+ :func:`indusagi.agent.parse_session_entries`,
20
+ :func:`indusagi.agent.build_session_context`,
21
+ :func:`indusagi.agent.convert_to_llm`) know how to parse and resolve the
22
+ legacy on-disk schema, and those internal schema literals stay inside the
23
+ framework package rather than being re-declared in the app.
24
+
25
+ THE MESSAGE ⇄ DICT CODEC (the plan's cross-cutting rule 2)
26
+ ----------------------------------------------------------
27
+ TS could ``JSON.stringify`` an ``AgentMessage`` because messages were plain
28
+ objects; the Python framework's messages are **frozen dataclasses** with
29
+ ``ClassVar`` role tags, so the NDJSON boundary needs an explicit codec.
30
+
31
+ Decision (checked ``indusagi.agent.sessions`` FIRST, per the plan):
32
+
33
+ - The framework owns a serializer-direction only: the *private*
34
+ ``sessions._jsonable`` projects dataclasses to TS-shaped JSON dicts but the
35
+ read side deliberately keeps loaded messages as plain dicts (the session
36
+ manager's losslessness stance) — there is **no public dataclass-rehydrating
37
+ codec to reuse**. Importing a private underscore helper from the framework
38
+ would couple the app to an implementation detail.
39
+ - So the one app-wide codec is hand-written here: :func:`message_to_dict`
40
+ (mirroring the framework's ``_jsonable`` contract: ``role``/``type``
41
+ ``ClassVar`` tags re-materialized, ``None`` fields dropped exactly like
42
+ ``JSON.stringify`` drops ``undefined``) and :func:`message_from_dict`
43
+ (rebuilding every known message shape — the three LLM roles from
44
+ :mod:`indusagi.ai` *and* the agent's custom session kinds
45
+ ``bashExecution``/``custom``/``branchSummary``/``compactionSummary`` from
46
+ :mod:`indusagi.agent` — by their ``role`` tag).
47
+ - **Unknown roles / dict-shaped notes pass through as dicts.** The framework
48
+ reads message fields through its tolerant accessor (``get_field``) and
49
+ ``convert_to_llm`` drops unrecognized roles, so a dict-shaped payload flows
50
+ through the whole system safely; round-tripping it as a dict is lossless
51
+ where guessing a constructor would not be.
52
+
53
+ Channels' ``LinkSnapshot``, ``/debug`` JSONL, and transcript-export's
54
+ ``PublishEntry`` adapter all consume this codec — no second serializer
55
+ anywhere (plan §5.2).
56
+
57
+ The ``role`` discriminant on a ``TranscriptEntry`` is the conductor's own —
58
+ ``role`` labels the *node*, not the LLM message. It is derived from the
59
+ carried message's ``role`` field so a round-trip is faithful, but the
60
+ transcript can also hold conductor-only nodes (``condense``, ``note``) that
61
+ the framework message union has no direct counterpart for.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import json
67
+ from collections.abc import Mapping, Sequence
68
+ from dataclasses import dataclass, fields as dataclass_fields, is_dataclass
69
+ from typing import Any
70
+
71
+ from indusagi.agent import (
72
+ build_session_context,
73
+ convert_to_llm,
74
+ load_entries_from_file,
75
+ parse_session_entries,
76
+ )
77
+ from indusagi.agent import (
78
+ BashExecutionMessage,
79
+ BranchSummaryMessage,
80
+ CompactionSummaryMessage,
81
+ CustomMessage,
82
+ )
83
+ from indusagi.ai import (
84
+ AssistantMessage,
85
+ ImageContent,
86
+ TextContent,
87
+ ThinkingContent,
88
+ ToolCall,
89
+ ToolResultMessage,
90
+ Usage,
91
+ UsageCost,
92
+ UserMessage,
93
+ )
94
+
95
+ from induscode.conductor.contract import (
96
+ TRANSCRIPT_SCHEMA,
97
+ SessionHead,
98
+ TranscriptEntry,
99
+ TranscriptRole,
100
+ )
101
+
102
+ __all__ = [
103
+ "ParsedSessionFile",
104
+ "branch_to_llm_messages",
105
+ "branch_to_messages",
106
+ "encode_entry",
107
+ "encode_head",
108
+ "import_legacy_file",
109
+ "import_legacy_text",
110
+ "message_from_dict",
111
+ "message_to_dict",
112
+ "parse_session_text",
113
+ "resolve_legacy_messages",
114
+ "role_for_message",
115
+ ]
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Tolerant field access (messages may be dataclasses or dict-shaped notes)
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def _field_of(message: Any, name: str, default: Any = None) -> Any:
124
+ """Read ``name`` off a message, whether it is a frozen dataclass
125
+ (attribute access) or a raw mapping (key access)."""
126
+ if isinstance(message, Mapping):
127
+ return message.get(name, default)
128
+ return getattr(message, name, default)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Role derivation
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def role_for_message(message: Any) -> TranscriptRole:
137
+ """Derive the conductor's node ``TranscriptRole`` from a framework
138
+ ``AgentMessage``.
139
+
140
+ The framework message union uses ``role`` values (``user``/``assistant``/
141
+ ``toolResult`` plus the custom app messages). We fold those onto the
142
+ transcript's own role vocabulary. Anything we don't recognize
143
+ (custom/notification-style messages) is filed as a ``note`` node so it
144
+ persists without claiming an LLM turn role.
145
+ """
146
+ match _field_of(message, "role"):
147
+ case "user":
148
+ return "user"
149
+ case "assistant":
150
+ return "assistant"
151
+ case "toolResult":
152
+ return "tool"
153
+ case "compactionSummary" | "branchSummary":
154
+ return "condense"
155
+ case _:
156
+ return "note"
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Message ⇄ dict codec
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def message_to_dict(message: Any) -> Any:
165
+ """Project a framework message (or any nested value) into its
166
+ JSON-serializable dict form.
167
+
168
+ Mirrors the framework's serialization contract: the ``role`` (messages) /
169
+ ``type`` (content parts) ``ClassVar`` tags are written first, ``None``
170
+ fields are dropped (``JSON.stringify`` drops ``undefined``), and field
171
+ names keep their TS camelCase spelling because the dataclasses already do.
172
+ Mappings and sequences pass through recursively; scalars pass through
173
+ untouched.
174
+ """
175
+ if message is None or isinstance(message, (str, int, float, bool)):
176
+ return message
177
+ if isinstance(message, Mapping):
178
+ return {key: message_to_dict(value) for key, value in message.items()}
179
+ if isinstance(message, (list, tuple)):
180
+ return [message_to_dict(item) for item in message]
181
+ if is_dataclass(message) and not isinstance(message, type):
182
+ out: dict[str, Any] = {}
183
+ role = getattr(message, "role", None)
184
+ if isinstance(role, str):
185
+ out["role"] = role
186
+ else:
187
+ kind = getattr(message, "type", None)
188
+ if isinstance(kind, str):
189
+ out["type"] = kind
190
+ for f in dataclass_fields(message):
191
+ value = getattr(message, f.name)
192
+ if value is None:
193
+ continue # JSON.stringify drops undefined fields
194
+ out[f.name] = message_to_dict(value)
195
+ return out
196
+ return str(message)
197
+
198
+
199
+ def _part_from_dict(part: Any) -> Any:
200
+ """Rebuild one content part from its dict form by its ``type`` tag;
201
+ unknown part shapes pass through unchanged."""
202
+ if not isinstance(part, Mapping):
203
+ return part
204
+ match part.get("type"):
205
+ case "text":
206
+ return TextContent(
207
+ text=str(part.get("text", "")),
208
+ textSignature=part.get("textSignature"),
209
+ )
210
+ case "thinking":
211
+ return ThinkingContent(
212
+ thinking=str(part.get("thinking", "")),
213
+ thinkingSignature=part.get("thinkingSignature"),
214
+ )
215
+ case "image":
216
+ return ImageContent(
217
+ data=str(part.get("data", "")),
218
+ mimeType=str(part.get("mimeType", "")),
219
+ )
220
+ case "toolCall":
221
+ return ToolCall(
222
+ id=str(part.get("id", "")),
223
+ name=str(part.get("name", "")),
224
+ arguments=dict(part.get("arguments") or {}),
225
+ thoughtSignature=part.get("thoughtSignature"),
226
+ )
227
+ case _:
228
+ return dict(part)
229
+
230
+
231
+ def _parts_from_dict(content: Any) -> tuple[Any, ...]:
232
+ """Rebuild a content-part list; tolerates a missing/odd payload."""
233
+ if not isinstance(content, Sequence) or isinstance(content, (str, bytes)):
234
+ return ()
235
+ return tuple(_part_from_dict(part) for part in content)
236
+
237
+
238
+ def _usage_from_dict(usage: Any) -> Usage:
239
+ """Rebuild a :class:`~indusagi.ai.Usage` record (zeros where absent)."""
240
+ cost = _field_of(usage, "cost") or {}
241
+ return Usage(
242
+ input=int(_field_of(usage, "input", 0) or 0),
243
+ output=int(_field_of(usage, "output", 0) or 0),
244
+ cacheRead=int(_field_of(usage, "cacheRead", 0) or 0),
245
+ cacheWrite=int(_field_of(usage, "cacheWrite", 0) or 0),
246
+ totalTokens=int(_field_of(usage, "totalTokens", 0) or 0),
247
+ cost=UsageCost(
248
+ input=float(_field_of(cost, "input", 0.0) or 0.0),
249
+ output=float(_field_of(cost, "output", 0.0) or 0.0),
250
+ cacheRead=float(_field_of(cost, "cacheRead", 0.0) or 0.0),
251
+ cacheWrite=float(_field_of(cost, "cacheWrite", 0.0) or 0.0),
252
+ total=float(_field_of(cost, "total", 0.0) or 0.0),
253
+ ),
254
+ )
255
+
256
+
257
+ def _timestamp_from(value: Any) -> int:
258
+ return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
259
+
260
+
261
+ def message_from_dict(payload: Any) -> Any:
262
+ """Rebuild a framework message from its dict form by its ``role`` tag.
263
+
264
+ Handles every shape the transcript can carry: the three LLM roles
265
+ (``user``/``assistant``/``toolResult``), the agent's custom session kinds
266
+ (``bashExecution``/``custom``/``branchSummary``/``compactionSummary``),
267
+ and — deliberately — **anything else passes through as the dict it is**
268
+ (conductor ``note`` payloads, app-registered kinds with unknown
269
+ constructors). A non-mapping payload also passes through untouched.
270
+ """
271
+ if not isinstance(payload, Mapping):
272
+ return payload
273
+ match payload.get("role"):
274
+ case "user":
275
+ content = payload.get("content")
276
+ return UserMessage(
277
+ content=content if isinstance(content, str) else _parts_from_dict(content),
278
+ timestamp=_timestamp_from(payload.get("timestamp")),
279
+ )
280
+ case "assistant":
281
+ return AssistantMessage(
282
+ content=_parts_from_dict(payload.get("content")),
283
+ api=str(payload.get("api", "")),
284
+ provider=str(payload.get("provider", "")),
285
+ model=str(payload.get("model", "")),
286
+ usage=_usage_from_dict(payload.get("usage")),
287
+ stopReason=payload.get("stopReason", "stop"),
288
+ errorMessage=payload.get("errorMessage"),
289
+ timestamp=_timestamp_from(payload.get("timestamp")),
290
+ )
291
+ case "toolResult":
292
+ return ToolResultMessage(
293
+ toolCallId=str(payload.get("toolCallId", "")),
294
+ toolName=str(payload.get("toolName", "")),
295
+ content=_parts_from_dict(payload.get("content")),
296
+ details=payload.get("details"),
297
+ isError=bool(payload.get("isError", False)),
298
+ timestamp=_timestamp_from(payload.get("timestamp")),
299
+ )
300
+ case "bashExecution":
301
+ return BashExecutionMessage(
302
+ command=str(payload.get("command", "")),
303
+ output=str(payload.get("output", "")),
304
+ exitCode=payload.get("exitCode"),
305
+ cancelled=bool(payload.get("cancelled", False)),
306
+ truncated=bool(payload.get("truncated", False)),
307
+ timestamp=_timestamp_from(payload.get("timestamp")),
308
+ fullOutputPath=payload.get("fullOutputPath"),
309
+ excludeFromContext=payload.get("excludeFromContext"),
310
+ )
311
+ case "custom":
312
+ content = payload.get("content")
313
+ return CustomMessage(
314
+ customType=str(payload.get("customType", "")),
315
+ content=content if isinstance(content, str) else _parts_from_dict(content),
316
+ display=bool(payload.get("display", False)),
317
+ timestamp=_timestamp_from(payload.get("timestamp")),
318
+ details=payload.get("details"),
319
+ )
320
+ case "branchSummary":
321
+ return BranchSummaryMessage(
322
+ summary=str(payload.get("summary", "")),
323
+ fromId=str(payload.get("fromId", "")),
324
+ timestamp=_timestamp_from(payload.get("timestamp")),
325
+ )
326
+ case "compactionSummary":
327
+ return CompactionSummaryMessage(
328
+ summary=str(payload.get("summary", "")),
329
+ tokensBefore=int(payload.get("tokensBefore", 0) or 0),
330
+ timestamp=_timestamp_from(payload.get("timestamp")),
331
+ )
332
+ case _:
333
+ # Unknown role / dict-shaped note: keep the dict verbatim. The
334
+ # framework's tolerant accessors handle it everywhere downstream.
335
+ return dict(payload)
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Entry ⇄ line (conductor's own format)
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ def _dump_line(line: Mapping[str, Any]) -> str:
344
+ # JSON.stringify's compact separators; non-ASCII kept literal.
345
+ return json.dumps(line, separators=(",", ":"), ensure_ascii=False)
346
+
347
+
348
+ def encode_entry(entry: TranscriptEntry) -> str:
349
+ """Encode one ``TranscriptEntry`` to its NDJSON entry line (no newline).
350
+
351
+ The wire shape is the conductor's fresh vocabulary — fields are renamed
352
+ away from the framework's session-manager schema on purpose: ``schema`` is
353
+ a namespaced string (``indus/transcript@1``), ``prev`` is the parent link
354
+ (the framework uses ``parentId``), ``message`` carries the framework
355
+ message verbatim (through the codec), ``at`` is the ISO timestamp (the
356
+ framework uses ``timestamp``).
357
+ """
358
+ line: dict[str, Any] = {
359
+ "schema": TRANSCRIPT_SCHEMA,
360
+ "kind": "entry",
361
+ "id": entry.id,
362
+ "prev": entry.parent,
363
+ "role": entry.role,
364
+ "message": message_to_dict(entry.content),
365
+ "at": entry.createdAt,
366
+ }
367
+ if entry.meta is not None:
368
+ line["meta"] = message_to_dict(entry.meta)
369
+ return _dump_line(line)
370
+
371
+
372
+ def encode_head(head: SessionHead) -> str:
373
+ """Encode the ``SessionHead`` to its NDJSON head line (no newline).
374
+
375
+ The head line is written as the *first* record of a session file: it pins
376
+ the schema and tracks which leaf the session currently points at, so a
377
+ reader can recover the active branch without scanning for the deepest
378
+ node. ``kind: "head"`` distinguishes it from entry lines.
379
+ """
380
+ return _dump_line(
381
+ {
382
+ "schema": TRANSCRIPT_SCHEMA,
383
+ "kind": "head",
384
+ "sessionId": head.sessionId,
385
+ "leaf": head.leaf,
386
+ }
387
+ )
388
+
389
+
390
+ def _line_to_entry(line: Mapping[str, Any]) -> TranscriptEntry | None:
391
+ """Re-lift a parsed entry line back into a ``TranscriptEntry``; a line
392
+ missing its envelope fields is dropped (tolerant parse)."""
393
+ entry_id = line.get("id")
394
+ role = line.get("role")
395
+ at = line.get("at")
396
+ if not isinstance(entry_id, str) or not isinstance(role, str) or not isinstance(at, str):
397
+ return None
398
+ prev = line.get("prev")
399
+ meta = line.get("meta")
400
+ return TranscriptEntry(
401
+ id=entry_id,
402
+ parent=prev if isinstance(prev, str) else None,
403
+ role=role,
404
+ content=message_from_dict(line.get("message")),
405
+ createdAt=at,
406
+ meta=dict(meta) if isinstance(meta, Mapping) else None,
407
+ )
408
+
409
+
410
+ @dataclass(frozen=True, slots=True)
411
+ class ParsedSessionFile:
412
+ """The result of parsing a whole session file: its head plus its entries
413
+ (TS ``ParsedSessionFile``)."""
414
+
415
+ head: SessionHead | None
416
+ entries: list[TranscriptEntry]
417
+
418
+
419
+ #: Sentinel distinguishing "no head line seen" from a head whose leaf is null.
420
+ _UNSET: Any = object()
421
+
422
+
423
+ def parse_session_text(session_id: str, text: str) -> ParsedSessionFile:
424
+ """Parse the raw text of a conductor session file into a head + entries.
425
+
426
+ Tolerant by design: blank lines are skipped, malformed lines are dropped,
427
+ and lines whose ``schema`` does not match ``indus/transcript@1`` are
428
+ ignored. The last head line wins (a file should carry exactly one, written
429
+ first and rewritten on branch). Entries keep file order.
430
+ """
431
+ entries: list[TranscriptEntry] = []
432
+ leaf: Any = _UNSET
433
+
434
+ for raw in text.split("\n"):
435
+ trimmed = raw.strip()
436
+ if len(trimmed) == 0:
437
+ continue
438
+ try:
439
+ parsed = json.loads(trimmed)
440
+ except ValueError:
441
+ continue
442
+ if not _is_file_line(parsed):
443
+ continue
444
+ if parsed["kind"] == "head":
445
+ # `.get(..., _UNSET)` mirrors TS `leaf = parsed.leaf`: a head line
446
+ # *without* a leaf field leaves the head unresolved (undefined),
447
+ # while an explicit `"leaf": null` pins an empty-transcript head.
448
+ leaf = parsed.get("leaf", _UNSET)
449
+ else:
450
+ entry = _line_to_entry(parsed)
451
+ if entry is not None:
452
+ entries.append(entry)
453
+
454
+ head = None if leaf is _UNSET else SessionHead(sessionId=session_id, leaf=leaf)
455
+ return ParsedSessionFile(head=head, entries=entries)
456
+
457
+
458
+ def _is_file_line(value: Any) -> bool:
459
+ """Narrow an unknown parsed value to a recognized ``indus/transcript@1``
460
+ line (TS ``isFileLine``)."""
461
+ if not isinstance(value, Mapping):
462
+ return False
463
+ if value.get("schema") != TRANSCRIPT_SCHEMA:
464
+ return False
465
+ return value.get("kind") in ("head", "entry")
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Conductor ⇄ framework (delegated message resolution)
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ def branch_to_messages(branch: Sequence[TranscriptEntry]) -> list[Any]:
474
+ """Project a resolved transcript branch down to the framework
475
+ ``AgentMessage`` list the agent loop consumes.
476
+
477
+ The branch is the root→leaf node list (the store's reducer produces it).
478
+ We pull the carried ``content`` payloads and hand them straight to the
479
+ agent; they are already framework messages. Conductor-only nodes whose
480
+ payload is not a real message would be filtered by the framework's own
481
+ ``convert_to_llm`` at call time, so we keep them here and let the loop's
482
+ converter decide.
483
+ """
484
+ return [entry.content for entry in branch]
485
+
486
+
487
+ def branch_to_llm_messages(branch: Sequence[TranscriptEntry]) -> list[Any]:
488
+ """Convert a transcript branch to *LLM-ready* ``Message`` list, delegating
489
+ entirely to the framework's :func:`indusagi.agent.convert_to_llm`.
490
+
491
+ This is the one place that must understand how each message variant
492
+ (including the framework's custom ``bashExecution``/``branchSummary``/…
493
+ messages) collapses into a plain LLM message — and that knowledge stays in
494
+ the framework. The conductor never re-implements it.
495
+ """
496
+ return convert_to_llm(branch_to_messages(branch))
497
+
498
+
499
+ def import_legacy_file(file_path: str) -> list[TranscriptEntry]:
500
+ """Lift a *legacy framework session file* into conductor
501
+ ``TranscriptEntry`` nodes, for one-way migration/import.
502
+
503
+ The legacy file is the framework's own internal JSONL format. We do
504
+ **not** parse its schema ourselves — we call the framework's published
505
+ loader (or its in-memory text parser), then map only the message-bearing
506
+ entries onto our envelope. Every framework-internal schema literal stays
507
+ behind that call.
508
+
509
+ Non-message bookkeeping entries (model/thinking changes, labels, raw
510
+ compaction markers) are dropped from the imported transcript: their effect
511
+ is already folded into the resolved messages we keep, and the conductor
512
+ tracks model/thinking in its own state rather than as transcript nodes.
513
+ """
514
+ return _map_framework_entries(load_entries_from_file(file_path))
515
+
516
+
517
+ def import_legacy_text(text: str) -> list[TranscriptEntry]:
518
+ """As :func:`import_legacy_file`, but from already-loaded text
519
+ (testing/import)."""
520
+ return _map_framework_entries(parse_session_entries(text))
521
+
522
+
523
+ def resolve_legacy_messages(file_path: str) -> list[Any]:
524
+ """Resolve a legacy framework file straight to the ``AgentMessage`` list
525
+ of its active branch, delegating to the framework's
526
+ :func:`indusagi.agent.build_session_context`.
527
+
528
+ Useful when the goal is to *seed* a fresh conductor session from a legacy
529
+ one without reproducing its tree: the framework walks its own parent links
530
+ and applies its own compaction/branch-summary resolution, and we receive a
531
+ flat message list to re-append as new transcript nodes.
532
+ """
533
+ file_entries = load_entries_from_file(file_path)
534
+ session_entries = [fe for fe in file_entries if _is_session_entry(fe)]
535
+ context = build_session_context(session_entries)
536
+ return context.messages
537
+
538
+
539
+ def _map_framework_entries(file_entries: Sequence[Mapping[str, Any]]) -> list[TranscriptEntry]:
540
+ """Map framework ``FileEntry`` records onto conductor entries, preserving
541
+ the tree links the framework already encodes.
542
+
543
+ We translate the framework's ``id``/``parentId``/``timestamp`` onto our
544
+ ``id``/``parent``/``createdAt``, derive our ``role`` from the carried
545
+ message, and stash the framework's original entry ``type`` under
546
+ ``meta["importedFrom"]`` for traceability. Only ``message``-typed entries
547
+ carry an ``AgentMessage``, so those are the ones we surface as transcript
548
+ nodes. The carried message stays in whatever representation the framework
549
+ loader yielded (plain dicts for on-disk files) — parity with the TS
550
+ import, and the framework's tolerant accessors consume it either way.
551
+ """
552
+ out: list[TranscriptEntry] = []
553
+ for fe in file_entries:
554
+ if not _is_session_entry(fe):
555
+ continue
556
+ if fe.get("type") != "message":
557
+ continue
558
+ out.append(
559
+ TranscriptEntry(
560
+ id=fe.get("id"),
561
+ parent=fe.get("parentId"),
562
+ role=role_for_message(fe.get("message")),
563
+ content=fe.get("message"),
564
+ createdAt=fe.get("timestamp"),
565
+ meta={"importedFrom": fe.get("type")},
566
+ )
567
+ )
568
+ return out
569
+
570
+
571
+ def _is_session_entry(entry: Mapping[str, Any]) -> bool:
572
+ """Separate framework ``SessionEntry`` records (which carry an ``id``)
573
+ from the file's header line. The header is the framework's ``session``
574
+ record; everything else is a real entry (TS ``isSessionEntry``)."""
575
+ return entry.get("type") != "session"