induscode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,549 @@
1
+ """Boot helper: assemble a :class:`~induscode.conductor.SessionConductor` for
2
+ a runner.
3
+
4
+ Port of TS ``src/boot/runners/session.ts`` — the agent-assembly choreography
5
+ all three runners share, so none of them re-derives the model id and session
6
+ options:
7
+
8
+ 1. Prime the OAuth adapter registry (explicitly — never at import time) and
9
+ best-effort export stored vault keys into the provider env vars
10
+ (:func:`prime_provider_env`). The *primary* credential path is the
11
+ injected per-call key resolver (:func:`build_key_resolver`); env priming
12
+ is kept only as the compatibility belt for the framework's env fallback,
13
+ per the port plan ("prefer resolver injection over env mutation").
14
+ 2. Resolve the model id (:func:`resolve_model_id`): an explicit ``--model``
15
+ wins; else the first authenticated provider's preferred *current* model;
16
+ else the catalog default.
17
+ 3. Select the tool deck (``provision_deck("all")`` filtered by ``--tools`` /
18
+ ``--no-tools``) and attach ``--mcp`` tools via the framework MCP pool.
19
+ 4. Compose the system prompt: ``--system`` replaces the tool-aware briefing,
20
+ ``--append-system`` appends.
21
+ 5. Build the conductor with the cwd-scoped sessions directory
22
+ (:func:`session_scope_dir`) and the live condense hook
23
+ (:func:`condense_transcript`).
24
+
25
+ The conductor itself constructs its framework agent lazily, so no model
26
+ client exists until the first turn runs.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import contextlib
32
+ import logging
33
+ import os
34
+ import re
35
+ from collections.abc import Awaitable, Callable, Iterator
36
+ from pathlib import Path
37
+ from typing import Any, Final
38
+
39
+ from indusagi.mcp import MCPClientPool, MCPClientPoolOptions, createMCPAgentToolFactory, loadMCPConfig
40
+
41
+ from induscode.briefing import BriefingContext, compose_briefing
42
+ from induscode.capability_deck import DeckContext, provision_deck
43
+ from induscode.conductor import (
44
+ AgentMessage,
45
+ AgentTool,
46
+ ConductorDeps,
47
+ MatchQuery,
48
+ ModelCatalog,
49
+ ModelMatcher,
50
+ SessionConductor,
51
+ SessionConductorOptions,
52
+ ThinkingLevel,
53
+ create_session_conductor,
54
+ )
55
+ from induscode.launch import register_built_in_oauth_providers
56
+ from induscode.launch.contract import AuthVault
57
+ from induscode.window_budget import BudgetPolicy, condense as condense_slice, plan_slice
58
+ from induscode.workspace import BRAND
59
+
60
+ from ..auth_vault import create_auth_vault
61
+ from ..contract import BootContext, Invocation
62
+
63
+ __all__ = [
64
+ "PREFERRED_DEFAULT",
65
+ "PROVIDER_ENV",
66
+ "build_key_resolver",
67
+ "build_session_conductor",
68
+ "condense_transcript",
69
+ "oneshot_prompts",
70
+ "prime_provider_env",
71
+ "resolve_model_id",
72
+ "session_scope_dir",
73
+ ]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Model resolution
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def _read_auth_data(ctx: BootContext) -> dict[str, Any]:
82
+ """Parse the raw auth.json map (``{provider: {account: record}}``), or an
83
+ empty dict on any read/parse failure."""
84
+ import json
85
+
86
+ path = Path(ctx.workspace.auth_path)
87
+ if not path.exists():
88
+ return {}
89
+ parsed = json.loads(path.read_text(encoding="utf-8"))
90
+ return parsed if isinstance(parsed, dict) else {}
91
+
92
+
93
+ def authenticated_providers(ctx: BootContext) -> list[str]:
94
+ """The provider ids that have at least one stored credential in the
95
+ on-disk auth vault, in the order they were saved.
96
+
97
+ Read straight from the auth.json map so the resolver can prefer a model
98
+ the user can actually call. Any read/parse failure yields an empty list —
99
+ the caller then falls back to the catalog default.
100
+ """
101
+ try:
102
+ data = _read_auth_data(ctx)
103
+ return [
104
+ provider
105
+ for provider, accounts in data.items()
106
+ if len(provider) > 0 and isinstance(accounts, dict) and len(accounts) > 0
107
+ ]
108
+ except Exception:
109
+ return []
110
+
111
+
112
+ #: Per-provider preferred default models, in priority order, by bare model id.
113
+ #:
114
+ #: A provider's catalog often lists deprecated models first. When defaulting
115
+ #: a session to an authenticated provider we pick the first of these the
116
+ #: catalog carries, so a fresh launch lands on a current, callable model
117
+ #: rather than a dead one. (TS table verbatim.)
118
+ PREFERRED_DEFAULT: Final[dict[str, tuple[str, ...]]] = {
119
+ "anthropic": (
120
+ "claude-sonnet-4-5",
121
+ "claude-haiku-4-5",
122
+ "claude-sonnet-4-6",
123
+ "claude-sonnet-4-0",
124
+ "claude-opus-4-5",
125
+ ),
126
+ "openai": ("gpt-5.1", "gpt-4.1", "gpt-4o"),
127
+ }
128
+
129
+
130
+ def _provider_default_model(catalog: ModelCatalog, provider: str) -> str | None:
131
+ """Choose the default model id for a provider: a preferred current model
132
+ when the catalog has one, else the last catalog entry (newest by scan
133
+ order), else the first. ``None`` when the provider contributed no
134
+ models."""
135
+ cards = catalog.by_provider(provider)
136
+ if len(cards) == 0:
137
+ return None
138
+ for bare in PREFERRED_DEFAULT.get(provider, ()):
139
+ wanted = f"{provider}/{bare}"
140
+ for card in cards:
141
+ if card.id == wanted:
142
+ return card.id
143
+ return cards[-1].id
144
+
145
+
146
+ def resolve_model_id(ctx: BootContext) -> str:
147
+ """Resolve the model id for this run.
148
+
149
+ Precedence: an explicit ``--model`` selector wins; otherwise a current
150
+ model of a provider the user has authenticated for (so a fresh launch
151
+ lands on a model the stored key can actually call); otherwise the catalog
152
+ default.
153
+
154
+ :param ctx: the boot context carrying the parsed invocation
155
+ :returns: the canonical model id to bind the session to
156
+ """
157
+ explicit = ctx.invocation.model_id
158
+ if explicit is not None and len(explicit.strip()) > 0:
159
+ return explicit
160
+ catalog = ModelCatalog()
161
+ for provider in authenticated_providers(ctx):
162
+ id = _provider_default_model(catalog, provider)
163
+ if id is not None:
164
+ return id
165
+ card = ModelMatcher(catalog).resolve(MatchQuery())
166
+ return card.id if card is not None else ""
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Credentials
171
+ # ---------------------------------------------------------------------------
172
+
173
+ #: The environment variable the framework reads a provider's api key from.
174
+ #:
175
+ #: Mirrors the framework's own provider→env mapping. The framework ``Agent``
176
+ #: resolves a key via the injected resolver *or* its env lookup; the app
177
+ #: stores keys in its own vault, so :func:`prime_provider_env` bridges the
178
+ #: two by exporting each stored key into the variable the framework would
179
+ #: look it up under. (TS table verbatim.)
180
+ PROVIDER_ENV: Final[dict[str, str]] = {
181
+ "anthropic": "ANTHROPIC_API_KEY",
182
+ "openai": "OPENAI_API_KEY",
183
+ "azure-openai-responses": "AZURE_OPENAI_API_KEY",
184
+ "google": "GEMINI_API_KEY",
185
+ "groq": "GROQ_API_KEY",
186
+ "cerebras": "CEREBRAS_API_KEY",
187
+ "xai": "XAI_API_KEY",
188
+ "openrouter": "OPENROUTER_API_KEY",
189
+ "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
190
+ "zai": "ZAI_API_KEY",
191
+ "mistral": "MISTRAL_API_KEY",
192
+ "minimax": "MINIMAX_API_KEY",
193
+ "minimax-cn": "MINIMAX_CN_API_KEY",
194
+ "opencode": "OPENCODE_API_KEY",
195
+ "sarvam": "SARVAM_API_KEY",
196
+ "krutrim": "KRUTRIM_API_KEY",
197
+ "nvidia": "NVIDIA_API_KEY",
198
+ }
199
+
200
+
201
+ def prime_provider_env(ctx: BootContext) -> None:
202
+ """Best-effort: export every stored credential into the environment the
203
+ framework reads.
204
+
205
+ Walks the on-disk vault and, for each provider with a usable key (an api
206
+ key, or an OAuth access token), sets the matching env var — unless one is
207
+ already present (an explicit ``ANTHROPIC_API_KEY=…`` in the shell always
208
+ wins). This is the *secondary* credential path: the injected per-call
209
+ resolver (:func:`build_key_resolver`) is primary; env priming only covers
210
+ framework code paths that consult the environment directly.
211
+ """
212
+ try:
213
+ data = _read_auth_data(ctx)
214
+ for provider, accounts in data.items():
215
+ if provider.startswith("_") or not isinstance(accounts, dict):
216
+ continue
217
+ env_var = PROVIDER_ENV.get(provider)
218
+ if env_var is None or os.environ.get(env_var):
219
+ continue
220
+ records = [a for a in accounts.values() if isinstance(a, dict)]
221
+ chosen = next((a for a in records if a.get("isDefault")), None)
222
+ if chosen is None and records:
223
+ chosen = records[0]
224
+ if chosen is None:
225
+ continue
226
+ value = chosen.get("key") if chosen.get("kind") == "apiKey" else chosen.get("access")
227
+ if isinstance(value, str) and len(value) > 0:
228
+ os.environ[env_var] = value
229
+ except Exception:
230
+ # Best-effort: a missing/corrupt vault just leaves the env untouched.
231
+ pass
232
+
233
+
234
+ def build_key_resolver(
235
+ vault: AuthVault,
236
+ requested_account: str | None = None,
237
+ ) -> Callable[[str], Awaitable[str | None]]:
238
+ """A per-call credential resolver backed by the on-disk auth vault.
239
+
240
+ The framework ``Agent`` calls this for every request. For a provider with
241
+ a stored account it returns the usable key — an api key, or a browser
242
+ sign-in access token that
243
+ :meth:`~induscode.boot.auth_vault.DiskAuthVault.read_usable_key`
244
+ refreshes on the fly when it has expired. This is the only credential
245
+ path for OAuth-only providers the framework has no env-var mapping for,
246
+ and it lets short-lived tokens rotate without restarting the session.
247
+
248
+ Returns ``None`` when nothing is stored, which lets the framework fall
249
+ back to its own environment lookup; never raises (a vault error degrades
250
+ to the env fallback rather than failing the turn).
251
+ """
252
+
253
+ async def get_api_key(provider: str) -> str | None:
254
+ try:
255
+ # Try, in order: the account `--account` requested (when given),
256
+ # the account flagged default, then the first stored account. The
257
+ # default/first fallback matters because re-logging into a
258
+ # provider that already had an account leaves fresh credentials
259
+ # usable but UNFLAGGED (`isDefault: False`) — without it a
260
+ # signed-in provider would resolve to no key and the turn would
261
+ # crash with "No API key for provider".
262
+ candidates: list[str | None] = [
263
+ requested_account,
264
+ await vault.default_account(provider),
265
+ next(iter(await vault.list_accounts(provider)), None),
266
+ ]
267
+ for account in candidates:
268
+ if account is None:
269
+ continue
270
+ key = await vault.read_usable_key(provider, account)
271
+ if key:
272
+ return key
273
+ return None
274
+ except Exception:
275
+ return None
276
+
277
+ return get_api_key
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Flag application (thinking / system / tools / mcp)
282
+ # ---------------------------------------------------------------------------
283
+
284
+ #: The reasoning-effort rungs the conductor accepts, for validating
285
+ #: ``--thinking``.
286
+ _THINKING_LEVELS: Final[frozenset[str]] = frozenset(
287
+ {"off", "minimal", "low", "medium", "high", "xhigh"}
288
+ )
289
+
290
+
291
+ def _resolve_thinking_level(raw: str | None) -> ThinkingLevel | None:
292
+ """Narrow a raw ``--thinking`` value to a ``ThinkingLevel``, ignoring
293
+ junk."""
294
+ if raw is not None and raw in _THINKING_LEVELS:
295
+ return raw # type: ignore[return-value] # narrowed by the guard
296
+ return None
297
+
298
+
299
+ def _resolve_text(value: str) -> str:
300
+ """Resolve a ``--system`` / ``--append-system`` value: read it as a file
301
+ when it names one on disk, otherwise use it verbatim — matching the
302
+ reference agent's file-or-literal handling so either a prompt file or
303
+ inline text works."""
304
+ try:
305
+ path = Path(value)
306
+ if path.exists():
307
+ return path.read_text(encoding="utf-8")
308
+ except Exception:
309
+ # Not a readable file: fall through and treat the value as literal.
310
+ pass
311
+ return value
312
+
313
+
314
+ def _canon_tool_name(name: str) -> str:
315
+ """Case-fold a tool id and strip ``_`` / ``-`` so user spellings line up
316
+ with deck ids (``web_fetch`` matches ``webfetch``)."""
317
+ return re.sub(r"[_-]", "", name.lower())
318
+
319
+
320
+ def select_tools(cwd: str, inv: Invocation) -> list[AgentTool]:
321
+ """Select the tool deck for the run, honouring ``--no-tools`` (empty) and
322
+ ``--tools`` (allow-list). Tool ids are matched case-insensitively with
323
+ ``_`` / ``-`` stripped."""
324
+ if inv.no_tools:
325
+ return []
326
+ all_tools = provision_deck("all", DeckContext(cwd=cwd)).tools()
327
+ if inv.tools is None or len(inv.tools) == 0:
328
+ return all_tools
329
+ allow = {_canon_tool_name(name) for name in inv.tools}
330
+ return [tool for tool in all_tools if _canon_tool_name(tool.name) in allow]
331
+
332
+
333
+ def compose_system(tools: list[AgentTool], inv: Invocation) -> str:
334
+ """Compose the run's system prompt: ``--system`` replaces the built-in
335
+ briefing, ``--append-system`` adds a trailing block, and both compose
336
+ (override then append). With neither, it is the tool-aware built-in
337
+ briefing."""
338
+ base = (
339
+ _resolve_text(inv.system)
340
+ if inv.system is not None
341
+ else compose_briefing(BriefingContext(tools=tools))
342
+ )
343
+ if inv.append_system is not None:
344
+ return f"{base}\n\n{_resolve_text(inv.append_system)}"
345
+ return base
346
+
347
+
348
+ @contextlib.contextmanager
349
+ def _silence_mcp_chatter() -> Iterator[None]:
350
+ """Silence MCP connection chatter for the duration of the load.
351
+
352
+ The framework pool reports per-server progress on stdout/stderr and via
353
+ logging; both would corrupt the console render or the JSON-RPC stdout
354
+ stream. Per the port plan this is *not* a print monkey-patch: the streams
355
+ are scope-redirected (:func:`contextlib.redirect_stdout` /
356
+ ``redirect_stderr``) and logging below ERROR is disabled, both restored
357
+ on exit. A set ``INDUSAGI_DEBUG`` keeps everything visible.
358
+ """
359
+ if os.environ.get(BRAND.env_debug):
360
+ yield
361
+ return
362
+ previous_disable = logging.root.manager.disable
363
+ logging.disable(logging.ERROR)
364
+ try:
365
+ with open(os.devnull, "w", encoding="utf-8") as sink:
366
+ with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
367
+ yield
368
+ finally:
369
+ logging.disable(previous_disable)
370
+
371
+
372
+ async def load_mcp_tools(inv: Invocation) -> list[AgentTool]:
373
+ """Connect the ``--mcp`` endpoints and return their tools as the
374
+ conductor's own ``AgentTool`` type (the framework MCP factory mints a
375
+ structurally compatible tool, so they concatenate onto the deck with no
376
+ adapter). Each path is a config file or a cwd to auto-detect under; a
377
+ server that fails to connect is skipped so the session stays usable.
378
+ Returns ``[]`` when ``--mcp`` was not given."""
379
+ paths = inv.mcp if inv.mcp is not None else ()
380
+ if len(paths) == 0:
381
+ return []
382
+
383
+ tools: list[AgentTool] = []
384
+ with _silence_mcp_chatter():
385
+ for path in paths:
386
+ try:
387
+ servers = loadMCPConfig(path)
388
+ if len(servers) == 0:
389
+ continue
390
+ pool = MCPClientPool(MCPClientPoolOptions(servers=servers))
391
+ await pool.connectAll()
392
+ for client in pool.getAllClients():
393
+ defs = await client.listTools()
394
+ for definition in defs:
395
+ tools.append(createMCPAgentToolFactory(definition, client)())
396
+ except Exception:
397
+ # Skip this endpoint; a bad MCP config must not sink the run.
398
+ continue
399
+ return tools
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Session directory scoping
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ def session_scope_dir(sessions_root: str | os.PathLike[str], cwd: str) -> str:
408
+ """The cwd-scoped session directory under the workspace ``sessions/``
409
+ root.
410
+
411
+ Sessions are partitioned per working directory (so ``--continue`` means
412
+ "the most recent session in THIS directory"). The cwd is slugged — every
413
+ non-alphanumeric run collapsed to a single dash — and wrapped in
414
+ ``--…--`` markers, matching the on-disk layout. Both the conductor
415
+ (writer) and the :class:`~induscode.sessions.SessionLibrary` (reader)
416
+ must agree on this, so it lives here and is shared by the repl runner.
417
+
418
+ :param sessions_root: the workspace ``sessions/`` directory
419
+ :param cwd: the run's working directory
420
+ """
421
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", cwd).strip("-")
422
+ return os.path.join(os.fspath(sessions_root), f"--{slug}--")
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Condense hook
427
+ # ---------------------------------------------------------------------------
428
+
429
+ #: Auto-compaction policy: condense only once the transcript outgrows a
430
+ #: recent ~6k-token tail, leaving that tail verbatim. Window-relative
431
+ #: defaults (0.75 / 6000 / 2048 — the window-budget defaults).
432
+ AUTO_CONDENSE_POLICY: Final[BudgetPolicy] = BudgetPolicy(
433
+ trigger_ratio=0.75, keep_recent=6000, reserve_tokens=2048
434
+ )
435
+
436
+
437
+ def _last_user_turn_start(messages: list[AgentMessage]) -> int:
438
+ """Index where the final user turn begins (its ``user`` message), or -1
439
+ if none."""
440
+ for i in range(len(messages) - 1, -1, -1):
441
+ message = messages[i]
442
+ role = message.get("role") if isinstance(message, dict) else getattr(message, "role", None)
443
+ if role == "user":
444
+ return i
445
+ return -1
446
+
447
+
448
+ async def condense_transcript(
449
+ messages: list[AgentMessage], force: bool = False
450
+ ) -> list[AgentMessage]:
451
+ """The conductor's condense hook — what ``/compact`` (and
452
+ auto-compaction) run.
453
+
454
+ Both fold older turns into one digest message and keep recent turns
455
+ verbatim; the difference is *how much* they keep:
456
+
457
+ - **Manual** (``force``): fold everything before the LAST user turn into
458
+ the digest, keeping only that final turn. This always reclaims context
459
+ on a multi-turn session — even a tiny one — which the planner's token
460
+ tail would leave untouched. Cutting at a user-message boundary keeps
461
+ tool call/result pairs intact (the prior turns are complete).
462
+ - **Auto**: budget-gated — fold only the head beyond the recent
463
+ ~6k-token tail (:data:`AUTO_CONDENSE_POLICY`).
464
+
465
+ Network-free by design: the summarizer emits a deterministic local digest
466
+ when given no model, so compaction never depends on a provider key or a
467
+ round-trip. Returns the input unchanged when there is nothing older to
468
+ fold (a single-turn session), so the conductor leaves the transcript
469
+ as-is.
470
+ """
471
+ if force:
472
+ cut = _last_user_turn_start(messages)
473
+ if cut <= 0:
474
+ return messages # 0 or 1 turn: nothing older to fold
475
+ summary = await condense_slice(messages[:cut])
476
+ return [summary.message, *messages[cut:]]
477
+ plan = plan_slice(messages, AUTO_CONDENSE_POLICY)
478
+ if plan.cut == 0 or len(plan.dropped) == 0:
479
+ return messages
480
+ summary = await condense_slice(list(plan.dropped))
481
+ return [summary.message, *plan.kept]
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Conductor assembly
486
+ # ---------------------------------------------------------------------------
487
+
488
+
489
+ async def build_session_conductor(ctx: BootContext) -> SessionConductor:
490
+ """Build the :class:`~induscode.conductor.SessionConductor` the runners
491
+ drive.
492
+
493
+ ``--account`` scopes the credential lookup; ``--tools`` / ``--no-tools``
494
+ pick the deck; ``--mcp`` attaches external tools; ``--system`` /
495
+ ``--append-system`` shape the prompt; ``--thinking`` sets the reasoning
496
+ effort. Each is applied here so the CLI flags actually reach the session
497
+ rather than being parsed and dropped.
498
+
499
+ :param ctx: the boot context (invocation + resolved workspace/resources)
500
+ :returns: a conductor bound to the resolved model and scoped to the run
501
+ cwd
502
+ """
503
+ # Prime the OAuth adapter registry explicitly (the vault's refresh path
504
+ # and the key resolver need it; nothing registers at import time).
505
+ register_built_in_oauth_providers()
506
+ prime_provider_env(ctx)
507
+
508
+ inv = ctx.invocation
509
+ model_id = resolve_model_id(ctx)
510
+ workspace = inv.cwd
511
+ cwd = workspace if workspace is not None else os.getcwd()
512
+ sessions_dir = session_scope_dir(ctx.workspace.sessions_dir, cwd)
513
+
514
+ get_api_key = build_key_resolver(
515
+ create_auth_vault(ctx.workspace.auth_path), inv.account
516
+ )
517
+ tools = [*select_tools(cwd, inv), *(await load_mcp_tools(inv))]
518
+ system = compose_system(tools, inv)
519
+ thinking = _resolve_thinking_level(inv.thinking)
520
+
521
+ return create_session_conductor(
522
+ SessionConductorOptions(
523
+ modelId=model_id,
524
+ tools=tools,
525
+ system=system,
526
+ getApiKey=get_api_key,
527
+ sessionsDir=sessions_dir,
528
+ thinking=thinking,
529
+ workspace=workspace,
530
+ ),
531
+ # The live condense hook: `/compact` and auto-compaction fold older
532
+ # turns into a digest. (Tests that build conductors directly keep the
533
+ # no-op default.)
534
+ ConductorDeps(condense=condense_transcript),
535
+ )
536
+
537
+
538
+ def oneshot_prompts(ctx: BootContext) -> list[str]:
539
+ """The prompts a oneshot run submits: the positional first prompt when
540
+ present. Empty when the invocation carried no request text (the caller
541
+ decides what to do then).
542
+
543
+ :param ctx: the boot context carrying the parsed invocation
544
+ """
545
+ prompts: list[str] = []
546
+ head = ctx.invocation.prompt
547
+ if head is not None and len(head) > 0:
548
+ prompts.append(head)
549
+ return prompts