induscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
"""Integration slash commands — auth launchers, the external bridges, and the
|
|
2
|
+
clipboard / export / share I/O verbs.
|
|
3
|
+
|
|
4
|
+
Port of TS ``src/console/slash/commands/integrations.ts``. This group is the
|
|
5
|
+
console's seam onto everything that lives *outside* a single turn: signing in
|
|
6
|
+
and out of a provider, wiring the MCP and memory surfaces, connecting an
|
|
7
|
+
external SaaS bridge, and moving the transcript out of the terminal
|
|
8
|
+
(clipboard, HTML export, a share link). The handlers do real work where a
|
|
9
|
+
backing capability exists — they render the live transcript to HTML through
|
|
10
|
+
the :func:`~induscode.transcript_export.publish_transcript` publisher, drive
|
|
11
|
+
an ``MCPClientPool`` over the workspace MCP config, reflect the
|
|
12
|
+
working-memory card from the capability deck, and reach the Composio catalog
|
|
13
|
+
through the SaaS gateway — and fall back to an *informative* status (never a
|
|
14
|
+
dead placeholder) when a backend is unreachable in the current environment.
|
|
15
|
+
|
|
16
|
+
The two family commands (``/memory``, ``/composio``) are built from
|
|
17
|
+
:class:`~induscode.console_slash.SubCommand` tables via
|
|
18
|
+
:func:`~induscode.console_slash.family_runner`, so their verb dispatch is a
|
|
19
|
+
lookup, not an ``if``-ladder. Overlay surfaces (``mcp``, ``memory``,
|
|
20
|
+
``composio``) carry the *gathered* text into the plugin modal payload
|
|
21
|
+
(``{"surface": …, "title": …, "text": …}``), so the dialog renders the real
|
|
22
|
+
state the command collected rather than a fixed line.
|
|
23
|
+
|
|
24
|
+
Port deltas, all locked by the plan's cross-cutting rules:
|
|
25
|
+
|
|
26
|
+
- **Uniform async** (rule 3): every ``run`` is ``async def`` and the
|
|
27
|
+
dispatcher awaits it. The TS ``void promise.then(...)`` chains on the
|
|
28
|
+
Composio verbs become *tracked* ``asyncio`` tasks on the per-console
|
|
29
|
+
holder's task set — never bare tasks; each settle coroutine swallows its
|
|
30
|
+
own failure into the overlay text exactly as the TS ``.catch`` did.
|
|
31
|
+
- **No module-level singletons** (rule 4): the TS module globals
|
|
32
|
+
(``mcpPool``/``mcpServers``, ``composioGateway``/``composioResolved``,
|
|
33
|
+
``memoryEnabled``) become fields on one per-console
|
|
34
|
+
:class:`IntegrationsRuntime` holder with a ``reset()`` for tests. The
|
|
35
|
+
command rows are minted by :func:`build_integration_commands` against one
|
|
36
|
+
holder, so each console owns its own bridge state.
|
|
37
|
+
- **Framework surface mapping** (analysis 03 §5): ``indusagi.mcp`` keeps the
|
|
38
|
+
camelCase pool surface (``loadMCPConfig`` / ``connectAll`` /
|
|
39
|
+
``listAllTools`` / ``isConnected`` / ``disconnectAll``); the SaaS gateway
|
|
40
|
+
moved to ``indusagi.connectors`` with a snake_case factory
|
|
41
|
+
(``create_composio_gateway(api_key=…)``) and snake_case report fields
|
|
42
|
+
(``enabled_tools`` / ``auth_url`` / ``account_id`` for the TS
|
|
43
|
+
``enabledTools`` / ``authUrl`` / ``accountId``).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import asyncio
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import shutil
|
|
52
|
+
import subprocess
|
|
53
|
+
from collections.abc import Mapping, Sequence
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any, Final
|
|
58
|
+
|
|
59
|
+
from indusagi.connectors import SaasGateway, create_composio_gateway
|
|
60
|
+
from indusagi.mcp import (
|
|
61
|
+
MCPClientPool,
|
|
62
|
+
MCPClientPoolOptions,
|
|
63
|
+
MCPConnectionOptions,
|
|
64
|
+
MCPToolDefinition,
|
|
65
|
+
loadMCPConfig,
|
|
66
|
+
)
|
|
67
|
+
from indusagi.react_ink import StatusMessage
|
|
68
|
+
|
|
69
|
+
from induscode.capability_deck import APP_NOVEL_CARDS, memory_card
|
|
70
|
+
from induscode.console_slash import (
|
|
71
|
+
FAMILY,
|
|
72
|
+
HANDLED,
|
|
73
|
+
SlashCommand,
|
|
74
|
+
SlashContext,
|
|
75
|
+
SlashOutcome,
|
|
76
|
+
SubCommand,
|
|
77
|
+
family_runner,
|
|
78
|
+
info,
|
|
79
|
+
warn,
|
|
80
|
+
)
|
|
81
|
+
from induscode.transcript_export import (
|
|
82
|
+
PublishEntry,
|
|
83
|
+
PublishMessage,
|
|
84
|
+
PublishOptions,
|
|
85
|
+
PublishRole,
|
|
86
|
+
publish_transcript,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"COMPOSIO_ENV_KEY",
|
|
91
|
+
"COMPOSIO_NO_KEY",
|
|
92
|
+
"IntegrationsRuntime",
|
|
93
|
+
"build_integration_commands",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# The per-console runtime holder (plan cross-cutting rule 4)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class IntegrationsRuntime:
|
|
104
|
+
"""The mutable bridge state one console's integration commands share.
|
|
105
|
+
|
|
106
|
+
The TS build kept this state in module globals so a ``/mcp connect``
|
|
107
|
+
followed by a later ``/mcp status`` acted on the same live pool; the
|
|
108
|
+
Python port threads one holder through the command closures instead, so
|
|
109
|
+
every console owns its own connections and tests get isolation via a
|
|
110
|
+
fresh holder (or :meth:`reset`).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
# The console's MCP client pool, lazily built from the workspace config.
|
|
114
|
+
mcp_pool: MCPClientPool | None = None
|
|
115
|
+
# The server configs the live pool was built from, for `status`.
|
|
116
|
+
mcp_servers: list[MCPConnectionOptions] = field(default_factory=list)
|
|
117
|
+
# The Composio gateway, built once from the env key (None = no key).
|
|
118
|
+
composio_gateway: SaasGateway | None = None
|
|
119
|
+
# Whether a gateway build has already been attempted (caches the miss).
|
|
120
|
+
composio_resolved: bool = False
|
|
121
|
+
# parity: /memory on|off flips this *reporting* flag only — it does not
|
|
122
|
+
# remove the memory tool from the live deck. TS quirk kept verbatim
|
|
123
|
+
# (plan rule 10); a real detach needs an explicit waiver first.
|
|
124
|
+
memory_enabled: bool = True
|
|
125
|
+
# Strong refs to in-flight settle tasks (rule 3: tracked, never bare).
|
|
126
|
+
tasks: set[asyncio.Task[None]] = field(default_factory=set)
|
|
127
|
+
|
|
128
|
+
def reset(self) -> None:
|
|
129
|
+
"""Drop every cached bridge handle and restore the defaults (tests)."""
|
|
130
|
+
self.mcp_pool = None
|
|
131
|
+
self.mcp_servers = []
|
|
132
|
+
self.composio_gateway = None
|
|
133
|
+
self.composio_resolved = False
|
|
134
|
+
self.memory_enabled = True
|
|
135
|
+
for task in self.tasks:
|
|
136
|
+
task.cancel()
|
|
137
|
+
self.tasks.clear()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _spawn(runtime: IntegrationsRuntime, ctx: SlashContext, coro: Any) -> None:
|
|
141
|
+
"""Run a settle coroutine as a tracked background task.
|
|
142
|
+
|
|
143
|
+
The coroutine is expected to handle its own failures (each Composio
|
|
144
|
+
settle path catches and reports through the overlay); a leak past that is
|
|
145
|
+
swallowed into a status warn rather than crashing the console — never a
|
|
146
|
+
bare task, never an unobserved exception (plan rule 3).
|
|
147
|
+
"""
|
|
148
|
+
task = asyncio.get_running_loop().create_task(coro)
|
|
149
|
+
runtime.tasks.add(task)
|
|
150
|
+
|
|
151
|
+
def _settled(done: asyncio.Task[None]) -> None:
|
|
152
|
+
runtime.tasks.discard(done)
|
|
153
|
+
if done.cancelled():
|
|
154
|
+
return
|
|
155
|
+
fault = done.exception()
|
|
156
|
+
if fault is not None:
|
|
157
|
+
ctx.set_status(warn(f"Background integration task failed: {fault}"))
|
|
158
|
+
|
|
159
|
+
task.add_done_callback(_settled)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Duck reads over framework messages
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read(message: object, key: str) -> object | None:
|
|
168
|
+
"""Read one structural field off a framework message — attribute access
|
|
169
|
+
for the framework dataclasses, key access for plain-dict stand-ins."""
|
|
170
|
+
if isinstance(message, Mapping):
|
|
171
|
+
return message.get(key)
|
|
172
|
+
return getattr(message, key, None)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Clipboard plumbing
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class _ClipboardWriter:
|
|
182
|
+
"""One platform clipboard writer candidate."""
|
|
183
|
+
|
|
184
|
+
bin: str
|
|
185
|
+
args: tuple[str, ...]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
#: The platform clipboard writers, tried in order until one accepts the text
|
|
189
|
+
#: on stdin. The first entry whose binary exists and exits cleanly wins:
|
|
190
|
+
#: - ``pbcopy`` — macOS.
|
|
191
|
+
#: - ``clip`` — Windows.
|
|
192
|
+
#: - ``xclip`` — X11 (selection set to the system clipboard).
|
|
193
|
+
#: - ``wl-copy`` — Wayland.
|
|
194
|
+
CLIPBOARD_WRITERS: Final[tuple[_ClipboardWriter, ...]] = (
|
|
195
|
+
_ClipboardWriter(bin="pbcopy", args=()),
|
|
196
|
+
_ClipboardWriter(bin="clip", args=()),
|
|
197
|
+
_ClipboardWriter(bin="xclip", args=("-selection", "clipboard")),
|
|
198
|
+
_ClipboardWriter(bin="wl-copy", args=()),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_clipboard(text: str) -> bool:
|
|
203
|
+
"""Best-effort write of ``text`` to the OS clipboard.
|
|
204
|
+
|
|
205
|
+
Walks :data:`CLIPBOARD_WRITERS`, feeding the text on stdin to each
|
|
206
|
+
candidate; returns ``True`` on the first writer that is present and exits
|
|
207
|
+
with status 0. A missing binary is skipped via :func:`shutil.which` (the
|
|
208
|
+
Python rendering of the TS swallowed ``ENOENT``); returns ``False`` when
|
|
209
|
+
no writer succeeds.
|
|
210
|
+
|
|
211
|
+
:param text: the string to place on the clipboard
|
|
212
|
+
"""
|
|
213
|
+
for writer in CLIPBOARD_WRITERS:
|
|
214
|
+
if shutil.which(writer.bin) is None:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
[writer.bin, *writer.args],
|
|
219
|
+
input=text,
|
|
220
|
+
capture_output=True,
|
|
221
|
+
encoding="utf-8",
|
|
222
|
+
)
|
|
223
|
+
except OSError:
|
|
224
|
+
# Writer is unavailable on this platform; fall through to the next.
|
|
225
|
+
continue
|
|
226
|
+
if result.returncode == 0:
|
|
227
|
+
return True
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Transcript text extraction
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _block_text(block: object) -> str | None:
|
|
237
|
+
"""The text of a ``text``-typed content block, or ``None`` otherwise."""
|
|
238
|
+
block_type = _read(block, "type")
|
|
239
|
+
text = _read(block, "text")
|
|
240
|
+
if block_type == "text" and isinstance(text, str):
|
|
241
|
+
return text
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _last_assistant_text(messages: Sequence[object]) -> str:
|
|
246
|
+
"""Pull the plain text out of the most recent assistant turn.
|
|
247
|
+
|
|
248
|
+
Scans newest-first for a message in the assistant role and concatenates
|
|
249
|
+
the text of its text blocks (reasoning and tool-call blocks are skipped,
|
|
250
|
+
since the clipboard wants the prose). Returns an empty string when there
|
|
251
|
+
is no assistant turn yet or it carried no text — the caller decides how
|
|
252
|
+
to surface that.
|
|
253
|
+
|
|
254
|
+
:param messages: the live transcript messages, oldest first
|
|
255
|
+
"""
|
|
256
|
+
for message in reversed(messages):
|
|
257
|
+
if _read(message, "role") != "assistant":
|
|
258
|
+
continue
|
|
259
|
+
content = _read(message, "content")
|
|
260
|
+
if isinstance(content, str) or not isinstance(content, Sequence):
|
|
261
|
+
continue
|
|
262
|
+
parts = [text for block in content if (text := _block_text(block)) is not None]
|
|
263
|
+
return "".join(parts).strip()
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Transcript HTML export
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
#: The transcript roles the publisher knows how to attribute.
|
|
272
|
+
PUBLISH_ROLES: Final[frozenset[str]] = frozenset(
|
|
273
|
+
{"user", "assistant", "tool", "system", "condense", "note"}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _to_publish_role(role: str) -> PublishRole:
|
|
278
|
+
"""Map a framework message role to a publisher role, folding any
|
|
279
|
+
unrecognised role into ``note`` so an exotic message still renders."""
|
|
280
|
+
if role in PUBLISH_ROLES:
|
|
281
|
+
return role # type: ignore[return-value] # narrowed by the set check
|
|
282
|
+
return "note"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _to_publish_entries(messages: Sequence[object]) -> list[PublishEntry]:
|
|
286
|
+
"""Project the live transcript onto the :class:`PublishEntry` list the
|
|
287
|
+
:func:`publish_transcript` publisher consumes. The framework message is
|
|
288
|
+
read structurally — only ``role``, ``content``, and the tool-result
|
|
289
|
+
linkage — so the publisher's own narrow ``PublishMessage`` view is
|
|
290
|
+
satisfied without redeclaring the framework union."""
|
|
291
|
+
entries: list[PublishEntry] = []
|
|
292
|
+
for message in messages:
|
|
293
|
+
role = _read(message, "role")
|
|
294
|
+
role_text = role if isinstance(role, str) else ""
|
|
295
|
+
content = _read(message, "content")
|
|
296
|
+
if isinstance(content, str) or isinstance(content, Sequence):
|
|
297
|
+
published: Any = content
|
|
298
|
+
else:
|
|
299
|
+
published = ""
|
|
300
|
+
tool_call_id = _read(message, "toolCallId")
|
|
301
|
+
tool_name = _read(message, "toolName")
|
|
302
|
+
entries.append(
|
|
303
|
+
PublishEntry(
|
|
304
|
+
role=_to_publish_role(role_text),
|
|
305
|
+
message=PublishMessage(
|
|
306
|
+
role=role_text,
|
|
307
|
+
content=published,
|
|
308
|
+
toolCallId=tool_call_id if isinstance(tool_call_id, str) else None,
|
|
309
|
+
toolName=tool_name if isinstance(tool_name, str) else None,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return entries
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _default_export_path() -> str:
|
|
317
|
+
"""Build the default ``transcript-<timestamp>.html`` path under the
|
|
318
|
+
workspace (the TS ISO stamp with ``[:.]`` folded to ``-``, ``T`` to
|
|
319
|
+
``_``, and the trailing ``Z`` dropped)."""
|
|
320
|
+
now = datetime.now(timezone.utc)
|
|
321
|
+
iso = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
322
|
+
stamp = re.sub(r"[:.]", "-", iso).replace("T", "_")
|
|
323
|
+
stamp = re.sub(r"Z$", "", stamp)
|
|
324
|
+
return os.path.abspath(os.path.join(os.getcwd(), f"transcript-{stamp}.html"))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True, slots=True)
|
|
328
|
+
class _ExportReport:
|
|
329
|
+
"""The outcome of one HTML export — the absolute path written on
|
|
330
|
+
success, or a typed failure carrying a human message."""
|
|
331
|
+
|
|
332
|
+
ok: bool
|
|
333
|
+
path: str = ""
|
|
334
|
+
reason: str = ""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _export_transcript_html(ctx: SlashContext, target: str) -> _ExportReport:
|
|
338
|
+
"""Render the live transcript to a standalone HTML document and write it
|
|
339
|
+
to disk.
|
|
340
|
+
|
|
341
|
+
The transcript is read from the conductor at call time and published
|
|
342
|
+
through the transcript-export subsystem; the only I/O here is the single
|
|
343
|
+
file write.
|
|
344
|
+
|
|
345
|
+
:param ctx: the slash context (the conductor is read for live messages)
|
|
346
|
+
:param target: an optional output path; a timestamped default otherwise
|
|
347
|
+
"""
|
|
348
|
+
messages = ctx.conductor.messages()
|
|
349
|
+
if len(messages) == 0:
|
|
350
|
+
return _ExportReport(
|
|
351
|
+
ok=False, reason="Nothing to export yet — the transcript is empty."
|
|
352
|
+
)
|
|
353
|
+
path = (
|
|
354
|
+
os.path.abspath(os.path.join(os.getcwd(), target))
|
|
355
|
+
if len(target) > 0
|
|
356
|
+
else _default_export_path()
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
html = publish_transcript(
|
|
360
|
+
_to_publish_entries(messages), PublishOptions(title="Session Transcript")
|
|
361
|
+
)
|
|
362
|
+
Path(path).write_text(html, encoding="utf-8")
|
|
363
|
+
return _ExportReport(ok=True, path=path)
|
|
364
|
+
except Exception as cause:
|
|
365
|
+
return _ExportReport(
|
|
366
|
+
ok=False, reason=f"Could not write the HTML export: {cause}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# MCP pool
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _load_mcp_pool(runtime: IntegrationsRuntime) -> MCPClientPool:
|
|
376
|
+
"""(Re)load the workspace MCP config and rebuild the pool from it."""
|
|
377
|
+
runtime.mcp_servers = loadMCPConfig(os.getcwd())
|
|
378
|
+
runtime.mcp_pool = MCPClientPool(MCPClientPoolOptions(servers=runtime.mcp_servers))
|
|
379
|
+
return runtime.mcp_pool
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _ensure_mcp_pool(runtime: IntegrationsRuntime) -> MCPClientPool:
|
|
383
|
+
"""The live pool, building one from config on first use."""
|
|
384
|
+
if runtime.mcp_pool is not None:
|
|
385
|
+
return runtime.mcp_pool
|
|
386
|
+
return _load_mcp_pool(runtime)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _gather_mcp_status(runtime: IntegrationsRuntime) -> str:
|
|
390
|
+
"""Gather a human-readable status block describing the configured MCP
|
|
391
|
+
servers, their live connection state, and the tool names each currently
|
|
392
|
+
exposes."""
|
|
393
|
+
if len(runtime.mcp_servers) == 0 and runtime.mcp_pool is None:
|
|
394
|
+
runtime.mcp_servers = loadMCPConfig(os.getcwd())
|
|
395
|
+
if len(runtime.mcp_servers) == 0:
|
|
396
|
+
return (
|
|
397
|
+
"No MCP servers are configured.\n"
|
|
398
|
+
"Add one to .indusvx/mcp.json, then run /mcp connect."
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
pool = runtime.mcp_pool
|
|
402
|
+
lines: list[str] = [f"Configured MCP servers ({len(runtime.mcp_servers)}):"]
|
|
403
|
+
|
|
404
|
+
tools_by_server: dict[str, list[MCPToolDefinition]] = {}
|
|
405
|
+
if pool is not None:
|
|
406
|
+
try:
|
|
407
|
+
tools_by_server = await pool.listAllTools()
|
|
408
|
+
except Exception:
|
|
409
|
+
tools_by_server = {}
|
|
410
|
+
|
|
411
|
+
for server in runtime.mcp_servers:
|
|
412
|
+
connected = pool.isConnected(server.name) if pool is not None else False
|
|
413
|
+
state = "connected" if connected else "not connected"
|
|
414
|
+
lines.append(f" • {server.name} — {state}")
|
|
415
|
+
tools = tools_by_server.get(server.name, [])
|
|
416
|
+
if len(tools) > 0:
|
|
417
|
+
lines.append(f" tools: {', '.join(t.name for t in tools)}")
|
|
418
|
+
return "\n".join(lines)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# MCP command
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _open_mcp_overlay(ctx: SlashContext, text: str) -> None:
|
|
427
|
+
"""Open the MCP plugin overlay populated with the gathered status text."""
|
|
428
|
+
ctx.open_modal("plugin", {"surface": "mcp", "title": "MCP servers", "text": text})
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _run_mcp(ctx: SlashContext, runtime: IntegrationsRuntime) -> SlashOutcome:
|
|
432
|
+
"""Run the ``/mcp`` verb against the workspace MCP pool."""
|
|
433
|
+
verb = ctx.args.strip().lower() or "status"
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
if verb in ("connect", "reconnect"):
|
|
437
|
+
pool = (
|
|
438
|
+
_load_mcp_pool(runtime)
|
|
439
|
+
if verb == "reconnect"
|
|
440
|
+
else _ensure_mcp_pool(runtime)
|
|
441
|
+
)
|
|
442
|
+
await pool.connectAll()
|
|
443
|
+
text = await _gather_mcp_status(runtime)
|
|
444
|
+
ctx.set_status(info(f"MCP {verb}ed."))
|
|
445
|
+
_open_mcp_overlay(ctx, text)
|
|
446
|
+
return HANDLED
|
|
447
|
+
if verb == "disconnect":
|
|
448
|
+
if runtime.mcp_pool is None:
|
|
449
|
+
ctx.set_status(info("No MCP connections are open."))
|
|
450
|
+
return HANDLED
|
|
451
|
+
await runtime.mcp_pool.disconnectAll()
|
|
452
|
+
ctx.set_status(info("Disconnected from all MCP servers."))
|
|
453
|
+
_open_mcp_overlay(ctx, await _gather_mcp_status(runtime))
|
|
454
|
+
return HANDLED
|
|
455
|
+
if verb == "tools":
|
|
456
|
+
pool = _ensure_mcp_pool(runtime)
|
|
457
|
+
tools_by_server: dict[str, list[MCPToolDefinition]] = {}
|
|
458
|
+
try:
|
|
459
|
+
tools_by_server = await pool.listAllTools()
|
|
460
|
+
except Exception:
|
|
461
|
+
tools_by_server = {}
|
|
462
|
+
names = [t.name for tools in tools_by_server.values() for t in tools]
|
|
463
|
+
text = (
|
|
464
|
+
"No MCP tools are loaded. Run /mcp connect first."
|
|
465
|
+
if len(names) == 0
|
|
466
|
+
else f"Loaded MCP tools ({len(names)}):\n " + "\n ".join(names)
|
|
467
|
+
)
|
|
468
|
+
_open_mcp_overlay(ctx, text)
|
|
469
|
+
return HANDLED
|
|
470
|
+
if verb in ("status", ""):
|
|
471
|
+
_open_mcp_overlay(ctx, await _gather_mcp_status(runtime))
|
|
472
|
+
return HANDLED
|
|
473
|
+
ctx.set_status(
|
|
474
|
+
warn("/mcp expects: connect, reconnect, disconnect, tools, or status.")
|
|
475
|
+
)
|
|
476
|
+
return HANDLED
|
|
477
|
+
except Exception as cause:
|
|
478
|
+
ctx.set_status(warn(f"MCP {verb} failed: {cause}"))
|
|
479
|
+
return HANDLED
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# Memory family
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _memory_tool_names() -> list[str]:
|
|
488
|
+
"""The model-facing tool name(s) the memory card contributes.
|
|
489
|
+
|
|
490
|
+
The card builds a single capability the model calls as ``memory``; the
|
|
491
|
+
card id is the deck-side handle for the same capability.
|
|
492
|
+
"""
|
|
493
|
+
return list(dict.fromkeys([str(memory_card.id), "memory"]))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _memory_present() -> bool:
|
|
497
|
+
"""Whether the working-memory card is part of the app's novel set."""
|
|
498
|
+
return any(card.id == memory_card.id for card in APP_NOVEL_CARDS)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _gather_memory_status(runtime: IntegrationsRuntime) -> str:
|
|
502
|
+
"""Build the gathered memory status text for the overlay / status line."""
|
|
503
|
+
if not _memory_present():
|
|
504
|
+
return "The working-memory capability is not registered in this build."
|
|
505
|
+
lines = [
|
|
506
|
+
f"Working memory: {'active' if runtime.memory_enabled else 'inactive'}.",
|
|
507
|
+
f"Tool: {', '.join(_memory_tool_names())}.",
|
|
508
|
+
memory_card.summary,
|
|
509
|
+
]
|
|
510
|
+
return "\n".join(lines)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _open_memory_overlay(ctx: SlashContext, text: str) -> None:
|
|
514
|
+
"""Open the memory plugin overlay populated with the gathered text."""
|
|
515
|
+
ctx.open_modal(
|
|
516
|
+
"plugin", {"surface": "memory", "title": "Working memory", "text": text}
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _memory_subs(runtime: IntegrationsRuntime) -> tuple[SubCommand, ...]:
|
|
521
|
+
"""The ``/memory`` sub-command table over one console's holder. Every
|
|
522
|
+
verb reflects (or best-effort toggles) the real working-memory card from
|
|
523
|
+
the capability deck — no dead placeholder."""
|
|
524
|
+
|
|
525
|
+
async def status(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
526
|
+
_open_memory_overlay(ctx, _gather_memory_status(runtime))
|
|
527
|
+
return HANDLED
|
|
528
|
+
|
|
529
|
+
async def on(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
530
|
+
if not _memory_present():
|
|
531
|
+
ctx.set_status(warn("No working-memory capability to enable in this build."))
|
|
532
|
+
return HANDLED
|
|
533
|
+
runtime.memory_enabled = True
|
|
534
|
+
ctx.set_status(info("Working memory marked active."))
|
|
535
|
+
_open_memory_overlay(ctx, _gather_memory_status(runtime))
|
|
536
|
+
return HANDLED
|
|
537
|
+
|
|
538
|
+
async def off(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
539
|
+
if not _memory_present():
|
|
540
|
+
ctx.set_status(warn("No working-memory capability to disable in this build."))
|
|
541
|
+
return HANDLED
|
|
542
|
+
runtime.memory_enabled = False
|
|
543
|
+
ctx.set_status(info("Working memory marked inactive."))
|
|
544
|
+
_open_memory_overlay(ctx, _gather_memory_status(runtime))
|
|
545
|
+
return HANDLED
|
|
546
|
+
|
|
547
|
+
async def tools(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
548
|
+
text = (
|
|
549
|
+
"Working-memory tools:\n " + "\n ".join(_memory_tool_names())
|
|
550
|
+
if _memory_present()
|
|
551
|
+
else "No working-memory capability is registered in this build."
|
|
552
|
+
)
|
|
553
|
+
_open_memory_overlay(ctx, text)
|
|
554
|
+
return HANDLED
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
SubCommand(verb="status", describe="show memory state + tool", run=status),
|
|
558
|
+
SubCommand(verb="on", describe="mark memory active", run=on),
|
|
559
|
+
SubCommand(verb="off", describe="mark memory inactive", run=off),
|
|
560
|
+
SubCommand(verb="tools", describe="list memory tool names", run=tools),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ---------------------------------------------------------------------------
|
|
565
|
+
# Composio family
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
#: The conventional environment variable carrying a Composio API key.
|
|
569
|
+
COMPOSIO_ENV_KEY: Final = "COMPOSIO_API_KEY"
|
|
570
|
+
|
|
571
|
+
#: The status line shown when no Composio key is configured.
|
|
572
|
+
COMPOSIO_NO_KEY: Final = (
|
|
573
|
+
f"Composio is not configured. Export {COMPOSIO_ENV_KEY} with a Composio API key, "
|
|
574
|
+
"then re-run /composio connect."
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _ensure_composio_gateway(runtime: IntegrationsRuntime) -> SaasGateway | None:
|
|
579
|
+
"""Resolve the Composio gateway, building it from the env key on first
|
|
580
|
+
use (and caching the miss so repeated verbs do not re-probe the env)."""
|
|
581
|
+
if runtime.composio_resolved:
|
|
582
|
+
return runtime.composio_gateway
|
|
583
|
+
runtime.composio_resolved = True
|
|
584
|
+
api_key = (os.environ.get(COMPOSIO_ENV_KEY) or "").strip()
|
|
585
|
+
if len(api_key) == 0:
|
|
586
|
+
runtime.composio_gateway = None
|
|
587
|
+
return None
|
|
588
|
+
try:
|
|
589
|
+
runtime.composio_gateway = create_composio_gateway(api_key=api_key)
|
|
590
|
+
except Exception:
|
|
591
|
+
runtime.composio_gateway = None
|
|
592
|
+
return runtime.composio_gateway
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _open_composio_overlay(ctx: SlashContext, text: str) -> None:
|
|
596
|
+
"""Open the Composio plugin overlay populated with gathered text."""
|
|
597
|
+
ctx.open_modal("plugin", {"surface": "composio", "title": "Composio", "text": text})
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def _gather_composio_status(gateway: SaasGateway) -> str:
|
|
601
|
+
"""Gather a status block of connected accounts + in-scope tool names."""
|
|
602
|
+
report = await gateway.status()
|
|
603
|
+
lines: list[str] = []
|
|
604
|
+
if len(report.accounts) == 0:
|
|
605
|
+
lines.append("No connected accounts.")
|
|
606
|
+
else:
|
|
607
|
+
lines.append(f"Connected accounts ({len(report.accounts)}):")
|
|
608
|
+
for account in report.accounts:
|
|
609
|
+
lines.append(f" • {account.toolkit} — {account.status} ({account.id})")
|
|
610
|
+
lines.append(
|
|
611
|
+
"No remote tools enabled yet. Run /composio enable <toolkit>."
|
|
612
|
+
if len(report.enabled_tools) == 0
|
|
613
|
+
else f"Enabled tools ({len(report.enabled_tools)}): "
|
|
614
|
+
+ ", ".join(report.enabled_tools)
|
|
615
|
+
)
|
|
616
|
+
return "\n".join(lines)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _composio_subs(runtime: IntegrationsRuntime) -> tuple[SubCommand, ...]:
|
|
620
|
+
"""The ``/composio`` sub-command table over one console's holder. Each
|
|
621
|
+
verb reaches the real Composio catalog through the SaaS gateway when a
|
|
622
|
+
key is configured, and reports the missing-key state specifically
|
|
623
|
+
otherwise. The connect/enable flows settle in tracked background tasks
|
|
624
|
+
that open the overlay on completion (the TS fire-and-forget promises)."""
|
|
625
|
+
|
|
626
|
+
async def status(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
627
|
+
gateway = _ensure_composio_gateway(runtime)
|
|
628
|
+
if gateway is None:
|
|
629
|
+
ctx.set_status(warn("Composio is not configured."))
|
|
630
|
+
_open_composio_overlay(ctx, COMPOSIO_NO_KEY)
|
|
631
|
+
return HANDLED
|
|
632
|
+
|
|
633
|
+
async def settle() -> None:
|
|
634
|
+
try:
|
|
635
|
+
text = await _gather_composio_status(gateway)
|
|
636
|
+
except Exception as cause:
|
|
637
|
+
_open_composio_overlay(ctx, f"Could not read Composio status: {cause}")
|
|
638
|
+
return
|
|
639
|
+
_open_composio_overlay(ctx, text)
|
|
640
|
+
|
|
641
|
+
_spawn(runtime, ctx, settle())
|
|
642
|
+
return HANDLED
|
|
643
|
+
|
|
644
|
+
async def accounts(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
645
|
+
gateway = _ensure_composio_gateway(runtime)
|
|
646
|
+
if gateway is None:
|
|
647
|
+
_open_composio_overlay(ctx, COMPOSIO_NO_KEY)
|
|
648
|
+
return HANDLED
|
|
649
|
+
|
|
650
|
+
async def settle() -> None:
|
|
651
|
+
try:
|
|
652
|
+
report = await gateway.status()
|
|
653
|
+
except Exception as cause:
|
|
654
|
+
_open_composio_overlay(ctx, f"Could not list accounts: {cause}")
|
|
655
|
+
return
|
|
656
|
+
text = (
|
|
657
|
+
"No connected Composio accounts."
|
|
658
|
+
if len(report.accounts) == 0
|
|
659
|
+
else f"Connected accounts ({len(report.accounts)}):\n"
|
|
660
|
+
+ "\n".join(
|
|
661
|
+
f" • {a.toolkit} — {a.status} ({a.id})" for a in report.accounts
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
_open_composio_overlay(ctx, text)
|
|
665
|
+
|
|
666
|
+
_spawn(runtime, ctx, settle())
|
|
667
|
+
return HANDLED
|
|
668
|
+
|
|
669
|
+
async def tools(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
670
|
+
gateway = _ensure_composio_gateway(runtime)
|
|
671
|
+
if gateway is None:
|
|
672
|
+
_open_composio_overlay(ctx, COMPOSIO_NO_KEY)
|
|
673
|
+
return HANDLED
|
|
674
|
+
|
|
675
|
+
async def settle() -> None:
|
|
676
|
+
try:
|
|
677
|
+
report = await gateway.status()
|
|
678
|
+
except Exception as cause:
|
|
679
|
+
_open_composio_overlay(ctx, f"Could not list tools: {cause}")
|
|
680
|
+
return
|
|
681
|
+
text = (
|
|
682
|
+
"No Composio tools enabled. Run /composio enable <toolkit>."
|
|
683
|
+
if len(report.enabled_tools) == 0
|
|
684
|
+
else f"Enabled Composio tools ({len(report.enabled_tools)}):\n "
|
|
685
|
+
+ "\n ".join(report.enabled_tools)
|
|
686
|
+
)
|
|
687
|
+
_open_composio_overlay(ctx, text)
|
|
688
|
+
|
|
689
|
+
_spawn(runtime, ctx, settle())
|
|
690
|
+
return HANDLED
|
|
691
|
+
|
|
692
|
+
async def connect(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
693
|
+
toolkit = rest.strip()
|
|
694
|
+
if len(toolkit) == 0:
|
|
695
|
+
ctx.set_status(warn("/composio connect expects: <toolkit> to link."))
|
|
696
|
+
return HANDLED
|
|
697
|
+
gateway = _ensure_composio_gateway(runtime)
|
|
698
|
+
if gateway is None:
|
|
699
|
+
_open_composio_overlay(ctx, COMPOSIO_NO_KEY)
|
|
700
|
+
return HANDLED
|
|
701
|
+
ctx.set_status(info(f"Starting Composio connect for {toolkit}..."))
|
|
702
|
+
|
|
703
|
+
async def settle() -> None:
|
|
704
|
+
try:
|
|
705
|
+
report = await gateway.connect(toolkit)
|
|
706
|
+
except Exception as cause:
|
|
707
|
+
_open_composio_overlay(ctx, f"Composio connect failed: {cause}")
|
|
708
|
+
return
|
|
709
|
+
lines = [f"Connect {toolkit}: {report.action}."]
|
|
710
|
+
if report.auth_url:
|
|
711
|
+
lines.append(f"Authorize at: {report.auth_url}")
|
|
712
|
+
if report.account_id:
|
|
713
|
+
lines.append(f"Account: {report.account_id}")
|
|
714
|
+
if report.reason:
|
|
715
|
+
lines.append(f"Reason: {report.reason}")
|
|
716
|
+
_open_composio_overlay(ctx, "\n".join(lines))
|
|
717
|
+
|
|
718
|
+
_spawn(runtime, ctx, settle())
|
|
719
|
+
return HANDLED
|
|
720
|
+
|
|
721
|
+
async def enable(ctx: SlashContext, rest: str) -> SlashOutcome:
|
|
722
|
+
toolkit = rest.strip()
|
|
723
|
+
if len(toolkit) == 0:
|
|
724
|
+
ctx.set_status(warn("/composio enable expects: <toolkit> to hydrate."))
|
|
725
|
+
return HANDLED
|
|
726
|
+
gateway = _ensure_composio_gateway(runtime)
|
|
727
|
+
if gateway is None:
|
|
728
|
+
_open_composio_overlay(ctx, COMPOSIO_NO_KEY)
|
|
729
|
+
return HANDLED
|
|
730
|
+
ctx.set_status(info(f"Enabling Composio toolkit {toolkit}..."))
|
|
731
|
+
|
|
732
|
+
async def settle() -> None:
|
|
733
|
+
try:
|
|
734
|
+
report = await gateway.enable(toolkit)
|
|
735
|
+
except Exception as cause:
|
|
736
|
+
_open_composio_overlay(ctx, f"Composio enable failed: {cause}")
|
|
737
|
+
return
|
|
738
|
+
text = (
|
|
739
|
+
f"Toolkit {toolkit} exposed no tools."
|
|
740
|
+
if len(report.hydrated) == 0
|
|
741
|
+
else f"Enabled {report.toolkit} ({len(report.hydrated)} tools"
|
|
742
|
+
+ (", cached" if report.cached else "")
|
|
743
|
+
+ "):\n "
|
|
744
|
+
+ "\n ".join(report.hydrated)
|
|
745
|
+
)
|
|
746
|
+
_open_composio_overlay(ctx, text)
|
|
747
|
+
|
|
748
|
+
_spawn(runtime, ctx, settle())
|
|
749
|
+
return HANDLED
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
SubCommand(verb="status", describe="show accounts + tools", run=status),
|
|
753
|
+
SubCommand(verb="accounts", describe="list connected accounts", run=accounts),
|
|
754
|
+
SubCommand(verb="tools", describe="list enabled tool names", run=tools),
|
|
755
|
+
SubCommand(verb="connect", describe="link a toolkit", run=connect),
|
|
756
|
+
SubCommand(verb="enable", describe="hydrate a toolkit's tools", run=enable),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
# Direct-action handlers
|
|
762
|
+
# ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
async def _run_copy(ctx: SlashContext) -> SlashOutcome:
|
|
766
|
+
"""``/copy`` — place the last assistant turn's text on the OS clipboard.
|
|
767
|
+
|
|
768
|
+
Reads the live transcript through the conductor, extracts the newest
|
|
769
|
+
assistant prose, and writes it through the best-effort clipboard walk.
|
|
770
|
+
Warns when there is nothing to copy, and again when no platform writer
|
|
771
|
+
accepted the text; otherwise confirms with an info toast.
|
|
772
|
+
"""
|
|
773
|
+
text = _last_assistant_text(ctx.conductor.messages())
|
|
774
|
+
if len(text) == 0:
|
|
775
|
+
ctx.set_status(warn("Nothing to copy yet — no assistant reply on screen."))
|
|
776
|
+
return HANDLED
|
|
777
|
+
if not _write_clipboard(text):
|
|
778
|
+
ctx.set_status(warn("Could not reach a clipboard tool on this system."))
|
|
779
|
+
return HANDLED
|
|
780
|
+
ctx.set_status(info("Copied the last reply to your clipboard."))
|
|
781
|
+
return HANDLED
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
async def _run_export(ctx: SlashContext) -> SlashOutcome:
|
|
785
|
+
"""``/export [path]`` — render the live transcript to a standalone HTML
|
|
786
|
+
document and write it to disk, reporting the absolute path written."""
|
|
787
|
+
result = _export_transcript_html(ctx, ctx.args.strip())
|
|
788
|
+
if not result.ok:
|
|
789
|
+
ctx.set_status(warn(result.reason))
|
|
790
|
+
return HANDLED
|
|
791
|
+
ctx.set_status(
|
|
792
|
+
StatusMessage(kind="success", text=f"Exported transcript to {result.path}")
|
|
793
|
+
)
|
|
794
|
+
return HANDLED
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
#: The pattern over ``gh`` stderr that suggests an authentication problem.
|
|
798
|
+
_GH_AUTH_HINT: Final = re.compile(r"auth|logged in|gh auth login", re.IGNORECASE)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
async def _run_share(ctx: SlashContext) -> SlashOutcome:
|
|
802
|
+
"""``/share`` — export the transcript to HTML, then publish it as a
|
|
803
|
+
secret GitHub gist via the ``gh`` CLI. On success the gist URL is
|
|
804
|
+
reported; when ``gh`` is missing or unauthenticated, that is reported
|
|
805
|
+
specifically with the local HTML path as a fallback the user can open or
|
|
806
|
+
upload manually."""
|
|
807
|
+
exported = _export_transcript_html(ctx, "")
|
|
808
|
+
if not exported.ok:
|
|
809
|
+
ctx.set_status(warn(exported.reason))
|
|
810
|
+
return HANDLED
|
|
811
|
+
|
|
812
|
+
try:
|
|
813
|
+
result = subprocess.run(
|
|
814
|
+
["gh", "gist", "create", exported.path, "--public=false"],
|
|
815
|
+
capture_output=True,
|
|
816
|
+
encoding="utf-8",
|
|
817
|
+
)
|
|
818
|
+
except (FileNotFoundError, OSError):
|
|
819
|
+
ctx.set_status(
|
|
820
|
+
warn(
|
|
821
|
+
f"GitHub CLI (gh) is not installed. Saved HTML locally at {exported.path}"
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
return HANDLED
|
|
825
|
+
|
|
826
|
+
if result.returncode != 0:
|
|
827
|
+
stderr = (result.stderr or "").strip()
|
|
828
|
+
auth_hint = (
|
|
829
|
+
"Run `gh auth login` to authenticate, then retry."
|
|
830
|
+
if _GH_AUTH_HINT.search(stderr)
|
|
831
|
+
else stderr or "gh gist create failed."
|
|
832
|
+
)
|
|
833
|
+
ctx.set_status(
|
|
834
|
+
warn(f"Share failed: {auth_hint} Saved HTML locally at {exported.path}")
|
|
835
|
+
)
|
|
836
|
+
return HANDLED
|
|
837
|
+
|
|
838
|
+
pieces = (result.stdout or "").strip().split()
|
|
839
|
+
url = pieces[0] if pieces else ""
|
|
840
|
+
ctx.set_status(
|
|
841
|
+
StatusMessage(
|
|
842
|
+
kind="success",
|
|
843
|
+
text=f"Shared as secret gist: {url}"
|
|
844
|
+
if len(url) > 0
|
|
845
|
+
else f"Shared. Local HTML: {exported.path}",
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
return HANDLED
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# ---------------------------------------------------------------------------
|
|
852
|
+
# Login routing
|
|
853
|
+
# ---------------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
async def _run_login(ctx: SlashContext) -> SlashOutcome:
|
|
857
|
+
"""``/login [provider]`` — open the sign-in surface. When a provider name
|
|
858
|
+
is supplied it is threaded into the sign-in payload so the overlay routes
|
|
859
|
+
straight to that provider's entry flow instead of the generic picker."""
|
|
860
|
+
provider = ctx.args.strip().lower()
|
|
861
|
+
if len(provider) > 0:
|
|
862
|
+
ctx.open_modal("signIn", {"providerId": provider})
|
|
863
|
+
else:
|
|
864
|
+
ctx.open_modal("signIn")
|
|
865
|
+
return HANDLED
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
async def _run_logout(ctx: SlashContext) -> SlashOutcome:
|
|
869
|
+
"""``/logout`` — open the sign-out confirmation."""
|
|
870
|
+
ctx.open_modal("signOut")
|
|
871
|
+
return HANDLED
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
# The exported group
|
|
876
|
+
# ---------------------------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def build_integration_commands(
|
|
880
|
+
runtime: IntegrationsRuntime | None = None,
|
|
881
|
+
) -> tuple[SlashCommand, ...]:
|
|
882
|
+
"""Mint the integration command group over one console's runtime holder.
|
|
883
|
+
|
|
884
|
+
The integration command group: auth launchers, the MCP/memory/composio
|
|
885
|
+
bridges, and the clipboard / export / share I/O verbs. Appended to the
|
|
886
|
+
slash catalog by the assembler (``builtins.build_catalog``) alongside the
|
|
887
|
+
other groups. Each call binds the rows to the given (or a fresh)
|
|
888
|
+
:class:`IntegrationsRuntime`, replacing the TS module-global state with
|
|
889
|
+
per-console state (plan cross-cutting rule 4).
|
|
890
|
+
|
|
891
|
+
:param runtime: the holder the bridge verbs share; fresh when omitted
|
|
892
|
+
"""
|
|
893
|
+
holder = runtime if runtime is not None else IntegrationsRuntime()
|
|
894
|
+
|
|
895
|
+
run_memory = family_runner(FAMILY.memory, _memory_subs(holder))
|
|
896
|
+
run_composio = family_runner(FAMILY.composio, _composio_subs(holder))
|
|
897
|
+
|
|
898
|
+
async def run_mcp(ctx: SlashContext) -> SlashOutcome:
|
|
899
|
+
return await _run_mcp(ctx, holder)
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
SlashCommand(
|
|
903
|
+
name="login",
|
|
904
|
+
summary="Sign in to a model provider.",
|
|
905
|
+
run=_run_login,
|
|
906
|
+
takes_args=True,
|
|
907
|
+
),
|
|
908
|
+
SlashCommand(
|
|
909
|
+
name="logout",
|
|
910
|
+
summary="Sign out of a model provider.",
|
|
911
|
+
run=_run_logout,
|
|
912
|
+
),
|
|
913
|
+
SlashCommand(
|
|
914
|
+
name="mcp",
|
|
915
|
+
summary="Manage MCP servers and their tools.",
|
|
916
|
+
run=run_mcp,
|
|
917
|
+
takes_args=True,
|
|
918
|
+
),
|
|
919
|
+
SlashCommand(
|
|
920
|
+
name="memory",
|
|
921
|
+
summary="Inspect and toggle the working-memory capability.",
|
|
922
|
+
run=run_memory,
|
|
923
|
+
family=FAMILY.memory,
|
|
924
|
+
takes_args=True,
|
|
925
|
+
),
|
|
926
|
+
SlashCommand(
|
|
927
|
+
name="composio",
|
|
928
|
+
summary="Connect and inspect Composio app bridges.",
|
|
929
|
+
run=run_composio,
|
|
930
|
+
family=FAMILY.composio,
|
|
931
|
+
takes_args=True,
|
|
932
|
+
),
|
|
933
|
+
SlashCommand(
|
|
934
|
+
name="copy",
|
|
935
|
+
summary="Copy the last reply to the clipboard.",
|
|
936
|
+
run=_run_copy,
|
|
937
|
+
),
|
|
938
|
+
SlashCommand(
|
|
939
|
+
name="export",
|
|
940
|
+
summary="Export the transcript to an HTML file.",
|
|
941
|
+
run=_run_export,
|
|
942
|
+
takes_args=True,
|
|
943
|
+
),
|
|
944
|
+
SlashCommand(
|
|
945
|
+
name="share",
|
|
946
|
+
summary="Share the session as a secret GitHub gist.",
|
|
947
|
+
run=_run_share,
|
|
948
|
+
),
|
|
949
|
+
)
|