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,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"
|