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
induscode/entry.py ADDED
@@ -0,0 +1,108 @@
1
+ """Process entry point — the console script ``pindus`` / ``induscode``
2
+ launches (port of TS ``src/entry.ts``).
3
+
4
+ Responsibilities, in order:
5
+
6
+ 1. Install exactly one logging filter that suppresses the transport-layer
7
+ ``[MCP]`` noise the MCP stack emits (unless the brand's debug env var is
8
+ set, in which case nothing is filtered).
9
+ 2. Hand the sliced ``argv`` to :func:`induscode.boot.boot` and adopt its
10
+ resolved exit code.
11
+
12
+ Port notes
13
+ ----------
14
+ - The TS entry set ``process.title`` to the primary bin name; Python has no
15
+ portable process titling without a third-party dependency
16
+ (``setproctitle``), so it is deliberately skipped (same decision as the
17
+ framework port).
18
+ - The TS ``import.meta`` program-entry guard (so importing the module does
19
+ not boot) is the console-script boundary here: importing
20
+ :mod:`induscode.entry` runs nothing; only the ``[project.scripts]`` shim
21
+ :func:`run` boots.
22
+ - The TS console filter wrapped ``console.log`` / ``console.error``; the
23
+ Python entry installs a :class:`logging.Filter` on the root logger instead
24
+ (no monkey-patching). Stream-level chatter during ``--mcp`` connects is
25
+ scope-silenced by the session assembly
26
+ (:mod:`induscode.boot.runners.session`).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import os
34
+ import sys
35
+ from collections.abc import Sequence
36
+ from typing import Final
37
+
38
+ from induscode.workspace import BRAND
39
+
40
+ __all__ = ["main", "run"]
41
+
42
+ #: Substring marking a transport-layer MCP log line we filter from the logs.
43
+ _MCP_NOISE_MARKER: Final[str] = "[MCP]"
44
+
45
+
46
+ class _McpNoiseFilter(logging.Filter):
47
+ """Drops any log record whose rendered message carries the
48
+ :data:`_MCP_NOISE_MARKER`, keeping the user's terminal clean of MCP
49
+ transport chatter."""
50
+
51
+ def filter(self, record: logging.LogRecord) -> bool:
52
+ try:
53
+ return _MCP_NOISE_MARKER not in record.getMessage()
54
+ except Exception:
55
+ return True
56
+
57
+
58
+ #: One-shot guard so repeated calls (or a re-import) install the filter once.
59
+ _noise_filter_installed: bool = False
60
+
61
+
62
+ def _install_mcp_noise_filter() -> None:
63
+ """Install the single, idempotent ``[MCP]`` log-noise filter on the root
64
+ logger. When the brand's debug env var is set the install is a no-op
65
+ (everything passes), so diagnostics are never hidden during debugging."""
66
+ global _noise_filter_installed
67
+ if _noise_filter_installed:
68
+ return
69
+ _noise_filter_installed = True
70
+
71
+ if os.environ.get(BRAND.env_debug):
72
+ return
73
+ logging.getLogger().addFilter(_McpNoiseFilter())
74
+
75
+
76
+ def main(argv: Sequence[str] | None = None) -> int:
77
+ """Process-level entry. Returns the exit code instead of raising
78
+ ``SystemExit`` so tests can call it directly.
79
+
80
+ :param argv: the already-sliced argument vector; defaults to
81
+ ``sys.argv[1:]``
82
+ """
83
+ args = list(argv) if argv is not None else sys.argv[1:]
84
+ _install_mcp_noise_filter()
85
+
86
+ # Imported here (not at module top) so `import induscode.entry` stays
87
+ # side-effect-light; the boot package pulls in the full launch surface.
88
+ from induscode.boot import boot
89
+
90
+ return asyncio.run(boot(args))
91
+
92
+
93
+ def run() -> None:
94
+ """The ``[project.scripts]`` shim — translate :func:`main` into an exit.
95
+
96
+ A ``BrokenPipeError`` (the reader of our stdout — e.g. ``| head`` — went
97
+ away) is treated as a clean early-terminated run, matching the Node
98
+ entry's silent-EPIPE behavior: stdout is parked on ``/dev/null`` so the
99
+ interpreter's shutdown flush cannot re-raise, and the process exits 0.
100
+ """
101
+ try:
102
+ code = main()
103
+ sys.stdout.flush()
104
+ except BrokenPipeError:
105
+ devnull = os.open(os.devnull, os.O_WRONLY)
106
+ os.dup2(devnull, sys.stdout.fileno())
107
+ code = 0
108
+ sys.exit(code)
@@ -0,0 +1,153 @@
1
+ """Insight subsystem — public barrel.
2
+
3
+ ``insight/`` is the agent's single observability plane, built as a **thin
4
+ wrapper over** ``indusagi.tracing`` (locked plan decision; analysis 05 §3) —
5
+ not a re-port of the 2.5k-LOC TS subsystem. Three modules:
6
+
7
+ - :mod:`~induscode.insight.wrapper` — the app vocabulary aliased onto the
8
+ framework: ``Probe``/``ProbeHandle``/``Signal`` over ``Segment``/
9
+ ``SegmentHandle``/``TraceSignal`` (kind map ``turn→inference``,
10
+ ``tool→action``, ``model→recall``), :func:`create_recorder` with an
11
+ injectable clock, the framework-delegating sampling gates (FNV-1a parity
12
+ verified), and the ``SecretScrubber``-backed redactor pinned to the app
13
+ token ``"[insight:scrubbed]"`` and the 4096-char cap;
14
+ - :mod:`~induscode.insight.collector` — the in-memory collector sink the
15
+ framework lacks (ported);
16
+ - :mod:`~induscode.insight.replay` — NDJSON replay readers over the
17
+ *framework's* record schema (ported; the framework has no reader).
18
+
19
+ The framework transport and terminal sinks are re-exported here unchanged
20
+ (``SignalChannel``, ``Sink``, ``ConsoleSink``, ``FileSink``, ``StreamSink``)
21
+ so consumers import the whole insight surface from :mod:`induscode.insight`
22
+ rather than reaching into ``indusagi.tracing`` or individual modules.
23
+ """
24
+
25
+ from indusagi.tracing.channel.signal import SignalChannel
26
+ from indusagi.tracing.redaction.secret_scrubber import SecretPattern
27
+ from indusagi.tracing.sinks import (
28
+ ConsoleSink,
29
+ ConsoleSinkOptions,
30
+ FileSink,
31
+ FileSinkOptions,
32
+ Sink,
33
+ StreamSink,
34
+ StreamSinkOptions,
35
+ )
36
+
37
+ from .collector import CollectorSink, create_collector_sink
38
+ from .replay import (
39
+ ChunkSource,
40
+ ReplayedTrail,
41
+ decode_record,
42
+ read_lines,
43
+ read_records,
44
+ record_to_probe,
45
+ replay_probes,
46
+ replay_signals,
47
+ replay_trails,
48
+ )
49
+ from .wrapper import (
50
+ APP_SECRET_PATTERNS,
51
+ DEFAULT_MAX_STRING_LENGTH,
52
+ DEFAULT_REDACTOR,
53
+ ID_WIDTHS,
54
+ KIND_TO_SEGMENT,
55
+ NOOP_HANDLE,
56
+ PASSTHRU_REDACTOR,
57
+ PROBE_KINDS,
58
+ REDACTED_TOKEN,
59
+ SEGMENT_TO_KIND,
60
+ TRUNCATION_SUFFIX,
61
+ CloseSignal,
62
+ InsightRecorder,
63
+ OpenSignal,
64
+ Probe,
65
+ ProbeAttributes,
66
+ ProbeFault,
67
+ ProbeHandle,
68
+ ProbeId,
69
+ ProbeKind,
70
+ ProbeOutcome,
71
+ ProbeStatus,
72
+ RatioGate,
73
+ SecretRedactor,
74
+ Signal,
75
+ SignalPhase,
76
+ TrailId,
77
+ TrailRecorder,
78
+ UpdateSignal,
79
+ always_gate,
80
+ create_recorder,
81
+ create_redactor,
82
+ fault_of,
83
+ is_close_signal,
84
+ is_closed_probe,
85
+ mint_probe_id,
86
+ mint_trail_id,
87
+ never_gate,
88
+ probe_from_segment,
89
+ ratio_gate,
90
+ )
91
+
92
+ __all__ = [
93
+ "APP_SECRET_PATTERNS",
94
+ "ChunkSource",
95
+ "CloseSignal",
96
+ "CollectorSink",
97
+ "ConsoleSink",
98
+ "ConsoleSinkOptions",
99
+ "DEFAULT_MAX_STRING_LENGTH",
100
+ "DEFAULT_REDACTOR",
101
+ "FileSink",
102
+ "FileSinkOptions",
103
+ "ID_WIDTHS",
104
+ "InsightRecorder",
105
+ "KIND_TO_SEGMENT",
106
+ "NOOP_HANDLE",
107
+ "OpenSignal",
108
+ "PASSTHRU_REDACTOR",
109
+ "PROBE_KINDS",
110
+ "Probe",
111
+ "ProbeAttributes",
112
+ "ProbeFault",
113
+ "ProbeHandle",
114
+ "ProbeId",
115
+ "ProbeKind",
116
+ "ProbeOutcome",
117
+ "ProbeStatus",
118
+ "REDACTED_TOKEN",
119
+ "RatioGate",
120
+ "ReplayedTrail",
121
+ "SEGMENT_TO_KIND",
122
+ "SecretPattern",
123
+ "SecretRedactor",
124
+ "Signal",
125
+ "SignalChannel",
126
+ "SignalPhase",
127
+ "Sink",
128
+ "StreamSink",
129
+ "StreamSinkOptions",
130
+ "TRUNCATION_SUFFIX",
131
+ "TrailId",
132
+ "TrailRecorder",
133
+ "UpdateSignal",
134
+ "always_gate",
135
+ "create_collector_sink",
136
+ "create_recorder",
137
+ "create_redactor",
138
+ "decode_record",
139
+ "fault_of",
140
+ "is_close_signal",
141
+ "is_closed_probe",
142
+ "mint_probe_id",
143
+ "mint_trail_id",
144
+ "never_gate",
145
+ "probe_from_segment",
146
+ "ratio_gate",
147
+ "read_lines",
148
+ "read_records",
149
+ "record_to_probe",
150
+ "replay_probes",
151
+ "replay_signals",
152
+ "replay_trails",
153
+ ]
@@ -0,0 +1,73 @@
1
+ """Collector sink — the in-memory test/inspection sink.
2
+
3
+ Ported from the TS ``sinks/stream.ts`` ``createCollectorSink`` because the
4
+ framework ships console/file/stream sinks but **no collector**. It is a
5
+ regular framework :class:`~indusagi.tracing.sinks.base.Sink`: it drains a
6
+ framework ``SignalChannel`` and appends **every** signal (open / update /
7
+ close — unlike the file and stream sinks, which persist closes only) to an
8
+ in-memory list, in arrival order.
9
+
10
+ What it stores are the raw framework ``TraceSignal`` values that rode the
11
+ channel; :attr:`CollectorSink.close_records` and :meth:`CollectorSink.probes`
12
+ offer the cooked views (terminal records, and app-vocabulary
13
+ :class:`~induscode.insight.wrapper.Probe` values) most tests want.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ from indusagi.tracing.channel.signal import CloseSignal as FwCloseSignal, TraceSignal
21
+ from indusagi.tracing.signal.segment import TraceRecord
22
+ from indusagi.tracing.sinks.base import Sink
23
+
24
+ from .wrapper import Probe, probe_from_segment
25
+
26
+ if TYPE_CHECKING:
27
+ from indusagi.tracing.channel.signal import SignalChannel
28
+
29
+ __all__ = [
30
+ "CollectorSink",
31
+ "create_collector_sink",
32
+ ]
33
+
34
+
35
+ class CollectorSink(Sink):
36
+ """A :class:`Sink` that accumulates every drained signal in memory.
37
+
38
+ The handiest sink for tests and one-shot inspection: drain a recorder's
39
+ channel through it, then read :attr:`signals`.
40
+ """
41
+
42
+ __slots__ = ("id", "_signals")
43
+
44
+ def __init__(self, id: str = "collector") -> None:
45
+ # A short identifier used in diagnostics (TS Sink.id).
46
+ self.id = id
47
+ self._signals: list[TraceSignal] = []
48
+
49
+ @property
50
+ def signals(self) -> tuple[TraceSignal, ...]:
51
+ """Every signal drained so far, in arrival order."""
52
+ return tuple(self._signals)
53
+
54
+ @property
55
+ def close_records(self) -> tuple[TraceRecord, ...]:
56
+ """The terminal records carried by the drained close signals."""
57
+ return tuple(
58
+ signal.record for signal in self._signals if isinstance(signal, FwCloseSignal)
59
+ )
60
+
61
+ def probes(self) -> tuple[Probe, ...]:
62
+ """App-vocabulary probe views of the drained close records."""
63
+ return tuple(probe_from_segment(record) for record in self.close_records)
64
+
65
+ async def drain(self, channel: "SignalChannel") -> None:
66
+ """Pull every signal from ``channel`` into the in-memory list."""
67
+ async for signal in channel:
68
+ self._signals.append(signal)
69
+
70
+
71
+ def create_collector_sink(id: str = "collector") -> CollectorSink:
72
+ """Build an in-memory :class:`CollectorSink` (TS ``createCollectorSink``)."""
73
+ return CollectorSink(id)
@@ -0,0 +1,305 @@
1
+ """Replay — read an NDJSON trace file back into probes and trails.
2
+
3
+ The framework has ``TraceRecord`` (the terminal segment value its sinks
4
+ persist) but **no reader**; this module ports the TS ``replay.ts`` readers
5
+ against the **framework's record schema** rather than the TS one.
6
+
7
+ **Schema choice (documented per plan risk 4).** This is a fresh ecosystem —
8
+ no TS-era NDJSON files will ever be read — so the wire format is whatever the
9
+ framework ``FileSink``/``StreamSink`` actually write
10
+ (``indusagi.tracing.sinks.base._record_to_json``): one flat camelCase JSON
11
+ object per line carrying the terminal record fields directly::
12
+
13
+ {"id": "...", "traceId": "...", "parentId": null, "kind": "action",
14
+ "name": "...", "startedAt": 1001, "endedAt": 1002, "status": "ok",
15
+ "attributes": {...}, "error": {"message": "..."}?}
16
+
17
+ Differences from the TS ``serialize.ts`` schema, accepted by the locked
18
+ wrapper decision:
19
+
20
+ - there is **no envelope** — no ``v`` version stamp, no ``phase``, no ``at``
21
+ emission clock; the record *is* the probe;
22
+ - only **close** signals are persisted (the framework sinks skip
23
+ ``open``/``update`` chatter), so a healthy trace replays as terminal
24
+ probes; :func:`replay_signals` derives the phase from the record itself
25
+ (terminal → ``close`` at ``endedAt``, otherwise ``open`` at ``startedAt``)
26
+ so a foreign writer that persists open records still replays;
27
+ - kinds on disk are **framework** kinds (``inference``/``action``/``recall``);
28
+ the readers map them back to the app vocabulary (``turn``/``tool``/``model``).
29
+
30
+ As in TS, a malformed line is skipped rather than fatal by default, so a
31
+ trace truncated by a crash still yields everything written before the break.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import codecs
37
+ import json
38
+ from collections.abc import AsyncIterable, Iterable, Mapping
39
+ from dataclasses import dataclass
40
+ from types import MappingProxyType
41
+ from typing import AsyncIterator, TypeAlias
42
+
43
+ from .wrapper import (
44
+ FAULT_NAME_KEY,
45
+ FAULT_STACK_KEY,
46
+ SEGMENT_TO_KIND,
47
+ CloseSignal,
48
+ OpenSignal,
49
+ Probe,
50
+ ProbeFault,
51
+ ProbeId,
52
+ Signal,
53
+ TrailId,
54
+ _fault_from_record,
55
+ )
56
+
57
+ __all__ = [
58
+ "ChunkSource",
59
+ "ReplayedTrail",
60
+ "decode_record",
61
+ "read_lines",
62
+ "read_records",
63
+ "record_to_probe",
64
+ "replay_probes",
65
+ "replay_signals",
66
+ "replay_trails",
67
+ ]
68
+
69
+ #: A source of raw text/byte chunks (a file read, stdin, a list of lines).
70
+ ChunkSource: TypeAlias = AsyncIterable[str | bytes] | Iterable[str | bytes]
71
+
72
+ #: One decoded NDJSON line — a plain dict in the framework's record schema.
73
+ TraceRecordDict: TypeAlias = dict[str, object]
74
+
75
+ _LINE_FEED = "\n"
76
+
77
+ # --------------------------------------------------------------------------
78
+ # Line + record decoding
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ async def _iter_chunks(source: ChunkSource) -> AsyncIterator[str | bytes]:
83
+ """Iterate a chunk source uniformly, whether sync or async."""
84
+ if hasattr(source, "__aiter__"):
85
+ async for chunk in source: # type: ignore[union-attr]
86
+ yield chunk
87
+ else:
88
+ for chunk in source: # type: ignore[union-attr]
89
+ yield chunk
90
+
91
+
92
+ async def read_lines(source: ChunkSource) -> AsyncIterator[str]:
93
+ """Split a chunk source into complete, non-blank text lines.
94
+
95
+ Buffers across chunks, splits on ``\\n`` only, reassembles multi-byte
96
+ runes split across byte chunks, and emits a trailing unterminated line at
97
+ end-of-stream so a final non-newline frame is not dropped.
98
+ """
99
+ decoder = codecs.getincrementaldecoder("utf-8")()
100
+ buffer = ""
101
+ async for chunk in _iter_chunks(source):
102
+ buffer += chunk if isinstance(chunk, str) else decoder.decode(chunk)
103
+ at = buffer.find(_LINE_FEED)
104
+ while at != -1:
105
+ line = buffer[:at]
106
+ buffer = buffer[at + 1 :]
107
+ if line.strip():
108
+ yield line
109
+ at = buffer.find(_LINE_FEED)
110
+ buffer += decoder.decode(b"", final=True)
111
+ if buffer.strip():
112
+ yield buffer
113
+
114
+
115
+ def decode_record(line: str) -> TraceRecordDict:
116
+ """Parse one NDJSON line into a record dict (the framework schema).
117
+
118
+ Raises ``json.JSONDecodeError`` on invalid JSON; callers validate the
119
+ shape before trusting it (see :func:`read_records`).
120
+ """
121
+ return json.loads(line)
122
+
123
+
124
+ def _is_valid_record(value: object) -> bool:
125
+ """Whether a parsed value is a structurally plausible framework record."""
126
+ if not isinstance(value, dict):
127
+ return False
128
+ if not isinstance(value.get("id"), str) or not value["id"]:
129
+ return False
130
+ if not isinstance(value.get("traceId"), str) or not value["traceId"]:
131
+ return False
132
+ parent = value.get("parentId")
133
+ if parent is not None and not isinstance(parent, str):
134
+ return False
135
+ if value.get("kind") not in SEGMENT_TO_KIND:
136
+ return False
137
+ if not isinstance(value.get("name"), str):
138
+ return False
139
+ if not isinstance(value.get("startedAt"), (int, float)):
140
+ return False
141
+ ended = value.get("endedAt")
142
+ if ended is not None and not isinstance(ended, (int, float)):
143
+ return False
144
+ if value.get("status") not in ("open", "ok", "error"):
145
+ return False
146
+ if not isinstance(value.get("attributes"), dict):
147
+ return False
148
+ return True
149
+
150
+
151
+ async def read_records(
152
+ source: ChunkSource,
153
+ *,
154
+ strict: bool = False,
155
+ ) -> AsyncIterator[TraceRecordDict]:
156
+ """Decode a chunk source into a stream of validated record dicts.
157
+
158
+ Malformed JSON or a record failing the shape check is skipped, or
159
+ rethrown when ``strict``.
160
+ """
161
+ async for line in read_lines(source):
162
+ try:
163
+ record = decode_record(line)
164
+ except json.JSONDecodeError:
165
+ if strict:
166
+ raise
167
+ continue
168
+ if not _is_valid_record(record):
169
+ if strict:
170
+ raise ValueError("insight replay: malformed trace record")
171
+ continue
172
+ yield record
173
+
174
+
175
+ def record_to_probe(record: TraceRecordDict) -> Probe:
176
+ """Project a validated record dict onto an app-vocabulary :class:`Probe`.
177
+
178
+ The fault's error ``name`` and ``stack`` ride the reserved attribute keys
179
+ the recorder writes at close (the framework's on-disk ``error`` carries only
180
+ ``message``); they are lifted back onto :attr:`Probe.fault` here and stripped
181
+ from the exposed attribute bag, so a failed probe round-trips its full fault
182
+ (message + name + stack) through NDJSON — matching the TS ``TraceRecord``.
183
+ """
184
+ attributes = dict(record["attributes"]) # type: ignore[arg-type]
185
+ error = record.get("error")
186
+ message: str | None = None
187
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
188
+ message = error["message"]
189
+ fault: ProbeFault | None = _fault_from_record(message, attributes)
190
+ # Strip the reserved fault keys so name/stack surface only on the fault.
191
+ attributes.pop(FAULT_NAME_KEY, None)
192
+ attributes.pop(FAULT_STACK_KEY, None)
193
+ ended = record.get("endedAt")
194
+ return Probe(
195
+ id=record["id"], # type: ignore[arg-type]
196
+ trail_id=record["traceId"], # type: ignore[arg-type]
197
+ parent_id=record.get("parentId"), # type: ignore[arg-type]
198
+ kind=SEGMENT_TO_KIND[record["kind"]], # type: ignore[index]
199
+ name=record["name"], # type: ignore[arg-type]
200
+ started_at=int(record["startedAt"]), # type: ignore[arg-type]
201
+ ended_at=int(ended) if ended is not None else None, # type: ignore[arg-type]
202
+ status=record["status"], # type: ignore[arg-type]
203
+ attributes=MappingProxyType(attributes),
204
+ fault=fault,
205
+ )
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # Probe / signal recovery
210
+ # --------------------------------------------------------------------------
211
+
212
+
213
+ async def replay_signals(
214
+ source: ChunkSource,
215
+ *,
216
+ strict: bool = False,
217
+ ) -> AsyncIterator[Signal]:
218
+ """Reconstruct the :data:`Signal` stream from a chunk source, in file order.
219
+
220
+ The framework schema carries no phase, so it is derived per record: a
221
+ terminal record (``status`` not ``open`` and a numeric ``endedAt``) yields
222
+ a :class:`CloseSignal` at ``endedAt``; anything else yields an
223
+ :class:`OpenSignal` at ``startedAt``.
224
+ """
225
+ async for record in read_records(source, strict=strict):
226
+ probe = record_to_probe(record)
227
+ if probe.status != "open" and probe.ended_at is not None:
228
+ yield CloseSignal(probe=probe, at=probe.ended_at)
229
+ else:
230
+ yield OpenSignal(probe=probe, at=probe.started_at)
231
+
232
+
233
+ async def replay_probes(
234
+ source: ChunkSource,
235
+ *,
236
+ strict: bool = False,
237
+ ) -> dict[ProbeId, Probe]:
238
+ """Collapse a trace into the final value of each probe, keyed by id.
239
+
240
+ The last record for an id wins; the returned dict preserves first-seen
241
+ insertion order (Python dict semantics match the TS ``Map.set`` here).
242
+ """
243
+ by_id: dict[ProbeId, Probe] = {}
244
+ async for record in read_records(source, strict=strict):
245
+ probe = record_to_probe(record)
246
+ by_id[probe.id] = probe
247
+ return by_id
248
+
249
+
250
+ # --------------------------------------------------------------------------
251
+ # Trail tree
252
+ # --------------------------------------------------------------------------
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class ReplayedTrail:
257
+ """One reconstructed trail: its root probe plus the parent→children index."""
258
+
259
+ trail_id: TrailId
260
+ # The root probe (parent_id is None), or None if the root was never seen.
261
+ root: Probe | None
262
+ # Every probe in the trail, keyed by id, in first-seen order.
263
+ probes: Mapping[ProbeId, Probe]
264
+ # Child probe ids per parent id; roots are listed under the None key.
265
+ children: Mapping[ProbeId | None, tuple[ProbeId, ...]]
266
+ # Whether every probe in the trail reached a terminal state.
267
+ complete: bool
268
+
269
+
270
+ def _build_trail(trail_id: TrailId, probes: dict[ProbeId, Probe]) -> ReplayedTrail:
271
+ """Assemble one :class:`ReplayedTrail` from its collapsed probe map."""
272
+ children: dict[ProbeId | None, list[ProbeId]] = {}
273
+ root: Probe | None = None
274
+ complete = True
275
+ for probe in probes.values():
276
+ children.setdefault(probe.parent_id, []).append(probe.id)
277
+ if probe.parent_id is None:
278
+ root = probe
279
+ if probe.status == "open" or probe.ended_at is None:
280
+ complete = False
281
+ return ReplayedTrail(
282
+ trail_id=trail_id,
283
+ root=root,
284
+ probes=MappingProxyType(probes),
285
+ children=MappingProxyType({k: tuple(v) for k, v in children.items()}),
286
+ complete=complete,
287
+ )
288
+
289
+
290
+ async def replay_trails(
291
+ source: ChunkSource,
292
+ *,
293
+ strict: bool = False,
294
+ ) -> list[ReplayedTrail]:
295
+ """Group a trace into per-trail trees, in first-probe-seen order.
296
+
297
+ Each :class:`ReplayedTrail` carries its probes, a parent→children
298
+ adjacency map, the located root, and a completeness flag.
299
+ """
300
+ probes_by_trail: dict[TrailId, dict[ProbeId, Probe]] = {}
301
+ for probe in (await replay_probes(source, strict=strict)).values():
302
+ probes_by_trail.setdefault(probe.trail_id, {})[probe.id] = probe
303
+ return [
304
+ _build_trail(trail_id, bucket) for trail_id, bucket in probes_by_trail.items()
305
+ ]