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
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
|
+
]
|