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,449 @@
|
|
|
1
|
+
"""TranscriptStore — the conductor's persistent, branchable transcript
|
|
2
|
+
(port of TS ``src/conductor/transcript-store/store.ts``).
|
|
3
|
+
|
|
4
|
+
The transcript is an append-only **tree** of
|
|
5
|
+
:class:`~induscode.conductor.contract.TranscriptEntry` nodes: every node names
|
|
6
|
+
its ``parent``, a single :class:`~induscode.conductor.contract.SessionHead`
|
|
7
|
+
tracks the active ``leaf``, and the active conversation is the ``parent``-chain
|
|
8
|
+
walked from that leaf back to a root.
|
|
9
|
+
|
|
10
|
+
Design stance (an independent persistence model):
|
|
11
|
+
|
|
12
|
+
- **Reducer-friendly.** The on-disk form is a flat NDJSON log; the in-memory
|
|
13
|
+
view is rebuilt by a pure :func:`replay` reducer over that log. State is a
|
|
14
|
+
value derived from the log, never mutated in place behind the scenes.
|
|
15
|
+
- **Fresh envelope.** Lines carry the ``indus/transcript@1`` schema with our
|
|
16
|
+
own field names (``prev``, ``at``, ``kind``), delegated to
|
|
17
|
+
:mod:`induscode.conductor.serialize` — not any framework-internal
|
|
18
|
+
session-manager schema.
|
|
19
|
+
- **Branch = move the head.** :meth:`TranscriptStore.branch_at` repoints the
|
|
20
|
+
leaf at an earlier node; the next :meth:`TranscriptStore.append` becomes
|
|
21
|
+
that node's child, forking a new path without rewriting history.
|
|
22
|
+
- **``path_to`` is the read primitive.** It resolves the root→leaf branch
|
|
23
|
+
(the message list the agent replays); the conductor's ``resume`` calls it
|
|
24
|
+
to rehydrate.
|
|
25
|
+
|
|
26
|
+
The store is storage-pluggable: a :class:`TranscriptBackend` abstracts where
|
|
27
|
+
bytes live, so tests inject an in-memory backend and the conductor binds a
|
|
28
|
+
filesystem one. Construction is synchronous and empty;
|
|
29
|
+
:meth:`TranscriptStore.load` hydrates from a backend.
|
|
30
|
+
|
|
31
|
+
Port note: the TS plain-object backends/clocks become frozen dataclasses of
|
|
32
|
+
callables — the same "bag of functions" shape, test-injectable field by field.
|
|
33
|
+
Disk I/O runs through :func:`asyncio.to_thread` so the async backend surface
|
|
34
|
+
never blocks the loop.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from ulid import ULID
|
|
47
|
+
|
|
48
|
+
from induscode.conductor.contract import (
|
|
49
|
+
TRANSCRIPT_SCHEMA,
|
|
50
|
+
SessionHead,
|
|
51
|
+
TranscriptEntry,
|
|
52
|
+
TranscriptRole,
|
|
53
|
+
)
|
|
54
|
+
from induscode.conductor.serialize import (
|
|
55
|
+
encode_entry,
|
|
56
|
+
encode_head,
|
|
57
|
+
parse_session_text,
|
|
58
|
+
role_for_message,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"TRANSCRIPT_SCHEMA",
|
|
63
|
+
"TranscriptBackend",
|
|
64
|
+
"TranscriptClock",
|
|
65
|
+
"TranscriptState",
|
|
66
|
+
"TranscriptStore",
|
|
67
|
+
"fs_backend",
|
|
68
|
+
"memory_backend",
|
|
69
|
+
"replay",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Storage backend
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class TranscriptBackend:
|
|
80
|
+
"""Where a transcript's NDJSON log lives (TS ``TranscriptBackend``).
|
|
81
|
+
|
|
82
|
+
The store appends lines and, on branch, rewrites the whole file; a backend
|
|
83
|
+
supplies those three primitives plus a way to resolve a session id to its
|
|
84
|
+
location. The default :func:`fs_backend` maps a session id to
|
|
85
|
+
``<dir>/<sessionId>.ndjson``; tests pass :func:`memory_backend` so no disk
|
|
86
|
+
is touched.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# Absolute location (path/key) a session id persists to.
|
|
90
|
+
locate: Callable[[str], str]
|
|
91
|
+
# Read the full NDJSON text for a session, or None if absent.
|
|
92
|
+
read: Callable[[str], Awaitable[str | None]]
|
|
93
|
+
# Append one already-encoded NDJSON line (no trailing newline) to a session.
|
|
94
|
+
append: Callable[[str, str], Awaitable[None]]
|
|
95
|
+
# Replace the whole NDJSON text for a session (used after a branch).
|
|
96
|
+
rewrite: Callable[[str, str], Awaitable[None]]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def memory_backend() -> TranscriptBackend:
|
|
100
|
+
"""An in-memory backend — the default when no persistence is wired."""
|
|
101
|
+
files: dict[str, list[str]] = {}
|
|
102
|
+
|
|
103
|
+
async def read(session_id: str) -> str | None:
|
|
104
|
+
lines = files.get(session_id)
|
|
105
|
+
return "\n".join(lines) if lines is not None else None
|
|
106
|
+
|
|
107
|
+
async def append(session_id: str, line: str) -> None:
|
|
108
|
+
files.setdefault(session_id, []).append(line)
|
|
109
|
+
|
|
110
|
+
async def rewrite(session_id: str, text: str) -> None:
|
|
111
|
+
files[session_id] = [] if len(text) == 0 else text.split("\n")
|
|
112
|
+
|
|
113
|
+
return TranscriptBackend(
|
|
114
|
+
locate=lambda session_id: f"mem:{session_id}",
|
|
115
|
+
read=read,
|
|
116
|
+
append=append,
|
|
117
|
+
rewrite=rewrite,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fs_backend(dir: str) -> TranscriptBackend:
|
|
122
|
+
"""A filesystem backend rooted at ``dir``."""
|
|
123
|
+
root = Path(dir)
|
|
124
|
+
|
|
125
|
+
def _file_of(session_id: str) -> Path:
|
|
126
|
+
return root / f"{session_id}.ndjson"
|
|
127
|
+
|
|
128
|
+
async def read(session_id: str) -> str | None:
|
|
129
|
+
def _read() -> str | None:
|
|
130
|
+
try:
|
|
131
|
+
return _file_of(session_id).read_text(encoding="utf-8")
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
return await asyncio.to_thread(_read)
|
|
136
|
+
|
|
137
|
+
async def append(session_id: str, line: str) -> None:
|
|
138
|
+
def _append() -> None:
|
|
139
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with open(_file_of(session_id), "a", encoding="utf-8") as fh:
|
|
141
|
+
fh.write(f"{line}\n")
|
|
142
|
+
|
|
143
|
+
await asyncio.to_thread(_append)
|
|
144
|
+
|
|
145
|
+
async def rewrite(session_id: str, text: str) -> None:
|
|
146
|
+
def _rewrite() -> None:
|
|
147
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
body = "" if len(text) == 0 else f"{text}\n"
|
|
149
|
+
_file_of(session_id).write_text(body, encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
await asyncio.to_thread(_rewrite)
|
|
152
|
+
|
|
153
|
+
return TranscriptBackend(
|
|
154
|
+
locate=lambda session_id: f"{dir}/{session_id}.ndjson",
|
|
155
|
+
read=read,
|
|
156
|
+
append=append,
|
|
157
|
+
rewrite=rewrite,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Reducer-friendly in-memory shape
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class TranscriptState:
|
|
168
|
+
"""The immutable derived state of a transcript: the node table keyed by
|
|
169
|
+
id, plus the current head. A pure value produced by :func:`replay`; the
|
|
170
|
+
store holds one and swaps it wholesale on each mutation rather than
|
|
171
|
+
mutating fields (TS ``TranscriptState``)."""
|
|
172
|
+
|
|
173
|
+
# Every node, keyed by id, in insertion order. Treated as read-only.
|
|
174
|
+
nodes: Mapping[str, TranscriptEntry]
|
|
175
|
+
# The active head (session id + current leaf).
|
|
176
|
+
head: SessionHead
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class TranscriptClock:
|
|
181
|
+
"""A clock + id seam so tests get deterministic ids/timestamps
|
|
182
|
+
(TS ``TranscriptClock``)."""
|
|
183
|
+
|
|
184
|
+
# Mint a fresh, sortable node id.
|
|
185
|
+
id: Callable[[], str]
|
|
186
|
+
# The current instant as an ISO-8601 string.
|
|
187
|
+
now: Callable[[], str]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _iso_now() -> str:
|
|
191
|
+
"""``new Date().toISOString()`` — millisecond precision, ``Z`` suffix."""
|
|
192
|
+
now = datetime.now(timezone.utc)
|
|
193
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
#: The live clock — ULIDs and wall-clock ISO timestamps.
|
|
197
|
+
_LIVE_CLOCK = TranscriptClock(id=lambda: str(ULID()), now=_iso_now)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def replay(
|
|
201
|
+
session_id: str,
|
|
202
|
+
entries: list[TranscriptEntry] | tuple[TranscriptEntry, ...],
|
|
203
|
+
leaf: str | None,
|
|
204
|
+
) -> TranscriptState:
|
|
205
|
+
"""Rebuild a :class:`TranscriptState` from an ordered entry list and a leaf.
|
|
206
|
+
|
|
207
|
+
Pure and total: the same inputs always yield the same state. The reducer
|
|
208
|
+
just indexes the entries by id (later ids overwrite earlier — append-only
|
|
209
|
+
logs never collide, but a rewrite stays idempotent) and pins the head.
|
|
210
|
+
"""
|
|
211
|
+
nodes: dict[str, TranscriptEntry] = {}
|
|
212
|
+
for entry in entries:
|
|
213
|
+
nodes[entry.id] = entry
|
|
214
|
+
return TranscriptState(nodes=nodes, head=SessionHead(sessionId=session_id, leaf=leaf))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# TranscriptStore
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TranscriptStore:
|
|
223
|
+
"""An append-only, branchable transcript bound to one session id.
|
|
224
|
+
|
|
225
|
+
Construct (empty), then either :meth:`append` fresh nodes or :meth:`load`
|
|
226
|
+
a persisted session. Every mutation appends to the backend and swaps the
|
|
227
|
+
derived :class:`TranscriptState`. Reads (:meth:`path_to`,
|
|
228
|
+
:meth:`messages`, :attr:`head`) are O(branch depth) walks over the
|
|
229
|
+
in-memory node table.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
__slots__ = ("_state", "_backend", "_clock")
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
session_id: str,
|
|
237
|
+
*,
|
|
238
|
+
backend: TranscriptBackend | None = None,
|
|
239
|
+
clock: TranscriptClock | None = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
self._backend = backend if backend is not None else memory_backend()
|
|
242
|
+
self._clock = clock if clock is not None else _LIVE_CLOCK
|
|
243
|
+
self._state = replay(session_id, [], None)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def session_id(self) -> str:
|
|
247
|
+
"""The session id this store is bound to."""
|
|
248
|
+
return self._state.head.sessionId
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def head(self) -> SessionHead:
|
|
252
|
+
"""The current head (session id + active leaf)."""
|
|
253
|
+
return self._state.head
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def size(self) -> int:
|
|
257
|
+
"""Number of nodes currently in the transcript."""
|
|
258
|
+
return len(self._state.nodes)
|
|
259
|
+
|
|
260
|
+
def locate(self, session_id: str | None = None) -> str:
|
|
261
|
+
"""Backend location this session persists to (for diagnostics/UI)."""
|
|
262
|
+
return self._backend.locate(session_id if session_id is not None else self.session_id)
|
|
263
|
+
|
|
264
|
+
def get(self, id: str) -> TranscriptEntry | None:
|
|
265
|
+
"""Look up a single node by id."""
|
|
266
|
+
return self._state.nodes.get(id)
|
|
267
|
+
|
|
268
|
+
async def append(
|
|
269
|
+
self,
|
|
270
|
+
content: Any,
|
|
271
|
+
role: TranscriptRole | None = None,
|
|
272
|
+
meta: Mapping[str, Any] | None = None,
|
|
273
|
+
) -> TranscriptEntry:
|
|
274
|
+
"""Append a framework ``AgentMessage`` as a child of the current leaf
|
|
275
|
+
and advance the head onto it. The node role is derived from the
|
|
276
|
+
message (or overridden via ``role``). Persists the new line, then
|
|
277
|
+
returns the new node.
|
|
278
|
+
|
|
279
|
+
This is the conductor's per-message write: one call per produced
|
|
280
|
+
message in a settled turn.
|
|
281
|
+
"""
|
|
282
|
+
entry = TranscriptEntry(
|
|
283
|
+
id=self._clock.id(),
|
|
284
|
+
parent=self._state.head.leaf,
|
|
285
|
+
role=role if role is not None else role_for_message(content),
|
|
286
|
+
content=content,
|
|
287
|
+
createdAt=self._clock.now(),
|
|
288
|
+
meta=dict(meta) if meta is not None else None,
|
|
289
|
+
)
|
|
290
|
+
await self._backend.append(self.session_id, encode_entry(entry))
|
|
291
|
+
self._commit(entry)
|
|
292
|
+
return entry
|
|
293
|
+
|
|
294
|
+
async def branch_at(self, id: str) -> None:
|
|
295
|
+
"""Repoint the head at an earlier node, forking a branch. The next
|
|
296
|
+
:meth:`append` becomes a child of ``id``; existing nodes are
|
|
297
|
+
untouched. Rewrites the head line.
|
|
298
|
+
|
|
299
|
+
Raises ``ValueError`` when ``id`` is not a known node.
|
|
300
|
+
"""
|
|
301
|
+
if id not in self._state.nodes:
|
|
302
|
+
raise ValueError(f'TranscriptStore.branch_at: unknown node "{id}"')
|
|
303
|
+
self._state = TranscriptState(
|
|
304
|
+
nodes=self._state.nodes,
|
|
305
|
+
head=SessionHead(sessionId=self.session_id, leaf=id),
|
|
306
|
+
)
|
|
307
|
+
await self._flush()
|
|
308
|
+
|
|
309
|
+
async def reset(self) -> None:
|
|
310
|
+
"""Reset the head to before any node; the next :meth:`append` starts a
|
|
311
|
+
new root. Used when re-editing the very first turn."""
|
|
312
|
+
self._state = TranscriptState(
|
|
313
|
+
nodes=self._state.nodes,
|
|
314
|
+
head=SessionHead(sessionId=self.session_id, leaf=None),
|
|
315
|
+
)
|
|
316
|
+
await self._flush()
|
|
317
|
+
|
|
318
|
+
def start_new_session(self, session_id: str) -> None:
|
|
319
|
+
"""Abandon the current conversation and begin a fresh, empty session
|
|
320
|
+
under a new id. All nodes are dropped and the leaf is reset to
|
|
321
|
+
``None``, so :meth:`messages` is empty and the next :meth:`append`
|
|
322
|
+
starts a new root; subsequent writes persist to the new session's
|
|
323
|
+
backing file (the old one is left intact on disk). This is the
|
|
324
|
+
``/clear`` (new-session) primitive — distinct from :meth:`reset`,
|
|
325
|
+
which keeps the nodes and id and only rewinds the head."""
|
|
326
|
+
self._state = replay(session_id, [], None)
|
|
327
|
+
|
|
328
|
+
def path_to(self, from_id: str | None = None) -> list[TranscriptEntry]:
|
|
329
|
+
"""Resolve the active branch: the root→leaf node list reached by
|
|
330
|
+
walking ``parent`` links up from ``leaf`` (or from ``from_id`` when
|
|
331
|
+
given), reversed to chronological order.
|
|
332
|
+
|
|
333
|
+
This is the conductor's read primitive — ``resume`` rehydrates the
|
|
334
|
+
agent from ``path_to()``, and the turn loop projects it to messages
|
|
335
|
+
via :meth:`messages`. Returns ``[]`` for an empty transcript or a
|
|
336
|
+
dangling leaf.
|
|
337
|
+
"""
|
|
338
|
+
start_id = from_id if from_id is not None else self._state.head.leaf
|
|
339
|
+
chain: list[TranscriptEntry] = []
|
|
340
|
+
cursor: str | None = start_id
|
|
341
|
+
seen: set[str] = set()
|
|
342
|
+
while cursor is not None:
|
|
343
|
+
if cursor in seen:
|
|
344
|
+
break # defensive: never loop on a corrupt parent link
|
|
345
|
+
seen.add(cursor)
|
|
346
|
+
node = self._state.nodes.get(cursor)
|
|
347
|
+
if node is None:
|
|
348
|
+
break
|
|
349
|
+
chain.append(node)
|
|
350
|
+
cursor = node.parent
|
|
351
|
+
chain.reverse()
|
|
352
|
+
return chain
|
|
353
|
+
|
|
354
|
+
def messages(self, from_id: str | None = None) -> list[Any]:
|
|
355
|
+
"""The active branch projected to the framework ``AgentMessage`` list."""
|
|
356
|
+
return [entry.content for entry in self.path_to(from_id)]
|
|
357
|
+
|
|
358
|
+
async def load(self, session_id: str | None = None) -> bool:
|
|
359
|
+
"""Hydrate this store from a persisted session, replacing its current
|
|
360
|
+
state.
|
|
361
|
+
|
|
362
|
+
Reads the backend, parses it with
|
|
363
|
+
:func:`~induscode.conductor.serialize.parse_session_text`, and
|
|
364
|
+
rebuilds state via the :func:`replay` reducer. If the file carried no
|
|
365
|
+
head line, the head is pinned to the deepest leaf reachable from the
|
|
366
|
+
entries. Returns ``True`` when a session was found and loaded,
|
|
367
|
+
``False`` when the backend had nothing.
|
|
368
|
+
"""
|
|
369
|
+
sid = session_id if session_id is not None else self.session_id
|
|
370
|
+
text = await self._backend.read(sid)
|
|
371
|
+
if text is None:
|
|
372
|
+
return False
|
|
373
|
+
parsed = parse_session_text(sid, text)
|
|
374
|
+
leaf = parsed.head.leaf if parsed.head is not None else _deepest_leaf(parsed.entries)
|
|
375
|
+
self._state = replay(sid, parsed.entries, leaf)
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
async def open(
|
|
380
|
+
cls,
|
|
381
|
+
session_id: str,
|
|
382
|
+
*,
|
|
383
|
+
backend: TranscriptBackend | None = None,
|
|
384
|
+
clock: TranscriptClock | None = None,
|
|
385
|
+
) -> "TranscriptStore | None":
|
|
386
|
+
"""Open a persisted session by id, returning a hydrated store (or
|
|
387
|
+
``None`` if the backend has no such session). The static counterpart
|
|
388
|
+
to :meth:`load`."""
|
|
389
|
+
store = cls(session_id, backend=backend, clock=clock)
|
|
390
|
+
found = await store.load(session_id)
|
|
391
|
+
return store if found else None
|
|
392
|
+
|
|
393
|
+
def state(self) -> TranscriptState:
|
|
394
|
+
"""A point-in-time copy of the derived state (for inspection/testing)."""
|
|
395
|
+
return self._state
|
|
396
|
+
|
|
397
|
+
# -- internals ----------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def _commit(self, entry: TranscriptEntry) -> None:
|
|
400
|
+
"""Fold a freshly appended node into state and advance the leaf onto it."""
|
|
401
|
+
nodes = dict(self._state.nodes)
|
|
402
|
+
nodes[entry.id] = entry
|
|
403
|
+
self._state = TranscriptState(
|
|
404
|
+
nodes=nodes,
|
|
405
|
+
head=SessionHead(sessionId=self.session_id, leaf=entry.id),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
async def _flush(self) -> None:
|
|
409
|
+
"""Rewrite the whole backend file (head line first, then every node line)."""
|
|
410
|
+
lines = [encode_head(self._state.head)]
|
|
411
|
+
for entry in self._state.nodes.values():
|
|
412
|
+
lines.append(encode_entry(entry))
|
|
413
|
+
await self._backend.rewrite(self.session_id, "\n".join(lines))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# Helpers
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _deepest_leaf(entries: list[TranscriptEntry]) -> str | None:
|
|
422
|
+
"""Pick the deepest leaf when a loaded file lacked an explicit head line:
|
|
423
|
+
the node with the longest ``parent`` chain (ties broken by latest id).
|
|
424
|
+
Best-effort recovery for hand-edited or legacy-imported transcripts."""
|
|
425
|
+
if len(entries) == 0:
|
|
426
|
+
return None
|
|
427
|
+
by_id = {entry.id: entry for entry in entries}
|
|
428
|
+
|
|
429
|
+
def depth_of(id: str) -> int:
|
|
430
|
+
depth = 0
|
|
431
|
+
cursor: str | None = id
|
|
432
|
+
seen: set[str] = set()
|
|
433
|
+
while cursor is not None and cursor not in seen:
|
|
434
|
+
seen.add(cursor)
|
|
435
|
+
node = by_id.get(cursor)
|
|
436
|
+
if node is None:
|
|
437
|
+
break
|
|
438
|
+
depth += 1
|
|
439
|
+
cursor = node.parent
|
|
440
|
+
return depth
|
|
441
|
+
|
|
442
|
+
best = entries[0]
|
|
443
|
+
best_depth = depth_of(best.id)
|
|
444
|
+
for entry in entries:
|
|
445
|
+
depth = depth_of(entry.id)
|
|
446
|
+
if depth > best_depth or (depth == best_depth and entry.id > best.id):
|
|
447
|
+
best = entry
|
|
448
|
+
best_depth = depth
|
|
449
|
+
return best.id
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Interactive console package (M5) — public barrel.
|
|
2
|
+
|
|
3
|
+
Port of TS ``src/console`` (``index.ts``, growing wave by wave). The console
|
|
4
|
+
is the *product shell* wrapped around the framework's rendering library: the
|
|
5
|
+
framework (``indusagi.react_ink``) supplies the message list, streaming
|
|
6
|
+
markdown, footer/status strips, and all dialog bodies; the console adds
|
|
7
|
+
everything that makes those a coding-agent app.
|
|
8
|
+
|
|
9
|
+
Landed waves:
|
|
10
|
+
|
|
11
|
+
- **The contract** (:mod:`.contract`) — the frozen type surface: the
|
|
12
|
+
:class:`ConsoleState` slice (rows/blocks/modal/status/scheme/toggles/busy;
|
|
13
|
+
the composer buffer/caret/history live in the framework editor), the
|
|
14
|
+
:data:`ConsoleEvent` union (TS ``domain:verb`` tags verbatim), the 12-kind
|
|
15
|
+
:data:`ModalKind` machine, the theme vocabulary, and the
|
|
16
|
+
:class:`ConsoleProps` / :class:`OverlayServices` host shapes. The M1 slash
|
|
17
|
+
framework types (:mod:`induscode.console_slash`) are re-exported here so
|
|
18
|
+
console consumers import one surface.
|
|
19
|
+
- **The reducer** (:mod:`.reducer`) — the single pure fold over
|
|
20
|
+
:class:`ConsoleState`.
|
|
21
|
+
- **The theme engine** (:mod:`.theme`) — four own-authored 9-stop ramps →
|
|
22
|
+
25 semantic tokens → the framework colour-key projection → one
|
|
23
|
+
``ThemeBundle`` per scheme (painter adapter + Textual Theme + Pygments
|
|
24
|
+
style), assembled once into :data:`THEMES` and resolved through
|
|
25
|
+
:func:`resolve_theme`.
|
|
26
|
+
- **Startup gathering** (:mod:`.startup`) — the pure resource/changelog
|
|
27
|
+
survey the banner draws.
|
|
28
|
+
- :mod:`.slash_commands` — the transcript and workbench slash command
|
|
29
|
+
groups, written against the M1 :mod:`induscode.console_slash` framework.
|
|
30
|
+
- :mod:`.input` — the console input layer: the console-intent vocabulary and
|
|
31
|
+
the chord→intent table the ``ConsoleApp`` BINDINGS derive from, the
|
|
32
|
+
double-tap chord latch + Ctrl+C exit-window helpers, and the slash/
|
|
33
|
+
``@``-path autocomplete providers feeding the framework ``PromptEditor``
|
|
34
|
+
(TS ``src/console/input``; ``paste.ts`` collapsed into the framework
|
|
35
|
+
``EditorCore`` paste markers).
|
|
36
|
+
- :mod:`.components` — the chrome widgets (``Banner`` / ``Emblem`` /
|
|
37
|
+
``StatusBar``); :mod:`.overlays` — the awaited ``push_screen_wait`` flows
|
|
38
|
+
behind :func:`~induscode.console.overlays.open_overlay`.
|
|
39
|
+
- **The surface** (:mod:`.app`) — :class:`ConsoleApp`, the Textual rewrite
|
|
40
|
+
of TS ``TerminalConsole.tsx``: the conductor signal→reducer projection,
|
|
41
|
+
the redesigned streaming-segment bookkeeping (with
|
|
42
|
+
``stream_parity_report``), submit routing (``!``/``!!`` → slash → prompt),
|
|
43
|
+
the INTENT_TABLE-derived BINDINGS + chord/exit-window machines, the
|
|
44
|
+
overlay workers, and the exit transcript.
|
|
45
|
+
- **The mount** (:mod:`.mount`) — :func:`mount_console`, the single entry
|
|
46
|
+
point a run mode awaits to take over the terminal; the boot repl runner's
|
|
47
|
+
default ``set_console_mount`` seam resolves to it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from induscode.console_slash import (
|
|
51
|
+
Handled,
|
|
52
|
+
OpenModal,
|
|
53
|
+
Prompt,
|
|
54
|
+
SlashCommand,
|
|
55
|
+
SlashContext,
|
|
56
|
+
SlashOutcome,
|
|
57
|
+
SlashRegistry,
|
|
58
|
+
SlashRun,
|
|
59
|
+
Unknown,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from .app import (
|
|
63
|
+
ConductorSignalMessage,
|
|
64
|
+
ConsoleApp,
|
|
65
|
+
count_providers,
|
|
66
|
+
project_snapshot,
|
|
67
|
+
read_branch,
|
|
68
|
+
read_double_escape_action,
|
|
69
|
+
)
|
|
70
|
+
from .contract import (
|
|
71
|
+
CONSOLE_EVENT_TYPES,
|
|
72
|
+
DEFAULT_SCHEME,
|
|
73
|
+
EMPTY_CONSOLE_STATE,
|
|
74
|
+
MODAL_KINDS,
|
|
75
|
+
NO_MODAL,
|
|
76
|
+
THEME_TOKEN_ROLES,
|
|
77
|
+
BlockAppend,
|
|
78
|
+
BlocksClear,
|
|
79
|
+
BusySet,
|
|
80
|
+
ConductorState,
|
|
81
|
+
ConsoleDispatch,
|
|
82
|
+
ConsoleEvent,
|
|
83
|
+
ConsoleEventType,
|
|
84
|
+
ConsoleHost,
|
|
85
|
+
ConsoleProps,
|
|
86
|
+
ConsoleReducer,
|
|
87
|
+
ConsoleState,
|
|
88
|
+
ConsoleTheme,
|
|
89
|
+
InkThemeAdapter,
|
|
90
|
+
ModalClose,
|
|
91
|
+
ModalKind,
|
|
92
|
+
ModalOpen,
|
|
93
|
+
ModalState,
|
|
94
|
+
OverlayServices,
|
|
95
|
+
RowsAppend,
|
|
96
|
+
RowsPatch,
|
|
97
|
+
RowsSet,
|
|
98
|
+
SchemeSet,
|
|
99
|
+
SessionConductor,
|
|
100
|
+
SessionSignal,
|
|
101
|
+
SessionSnapshot,
|
|
102
|
+
StartOAuthLogin,
|
|
103
|
+
StatusClear,
|
|
104
|
+
StatusMessage,
|
|
105
|
+
StatusSet,
|
|
106
|
+
ThemePalette,
|
|
107
|
+
ThemeScheme,
|
|
108
|
+
ThemeToken,
|
|
109
|
+
ThemeTokens,
|
|
110
|
+
Tick,
|
|
111
|
+
ToggleImages,
|
|
112
|
+
ToggleReasoning,
|
|
113
|
+
ToolExecutionState,
|
|
114
|
+
UiDisplayBlock,
|
|
115
|
+
ViewRow,
|
|
116
|
+
ViewRowKind,
|
|
117
|
+
is_theme_scheme,
|
|
118
|
+
transition_modal,
|
|
119
|
+
)
|
|
120
|
+
from .mount import mount_console
|
|
121
|
+
from .reducer import console_reducer, init_console_state
|
|
122
|
+
from .startup import (
|
|
123
|
+
StartupChangelog,
|
|
124
|
+
StartupChangelogMode,
|
|
125
|
+
StartupInputs,
|
|
126
|
+
StartupMap,
|
|
127
|
+
StartupNotice,
|
|
128
|
+
StartupNoticeKind,
|
|
129
|
+
StartupSection,
|
|
130
|
+
gather_changelog,
|
|
131
|
+
gather_startup,
|
|
132
|
+
)
|
|
133
|
+
from .theme import (
|
|
134
|
+
DAYLIGHT_CB_PALETTE,
|
|
135
|
+
DAYLIGHT_PALETTE,
|
|
136
|
+
MIDNIGHT_CB_PALETTE,
|
|
137
|
+
MIDNIGHT_PALETTE,
|
|
138
|
+
PALETTES,
|
|
139
|
+
THEME_SCHEMES,
|
|
140
|
+
THEMES,
|
|
141
|
+
derive_tokens,
|
|
142
|
+
framework_colors,
|
|
143
|
+
resolve_theme,
|
|
144
|
+
theme_adapter,
|
|
145
|
+
theme_bundle,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
__all__ = [
|
|
149
|
+
"BlockAppend",
|
|
150
|
+
"BlocksClear",
|
|
151
|
+
"BusySet",
|
|
152
|
+
"CONSOLE_EVENT_TYPES",
|
|
153
|
+
"ConductorSignalMessage",
|
|
154
|
+
"ConductorState",
|
|
155
|
+
"ConsoleApp",
|
|
156
|
+
"ConsoleDispatch",
|
|
157
|
+
"ConsoleEvent",
|
|
158
|
+
"ConsoleEventType",
|
|
159
|
+
"ConsoleHost",
|
|
160
|
+
"ConsoleProps",
|
|
161
|
+
"ConsoleReducer",
|
|
162
|
+
"ConsoleState",
|
|
163
|
+
"ConsoleTheme",
|
|
164
|
+
"DAYLIGHT_CB_PALETTE",
|
|
165
|
+
"DAYLIGHT_PALETTE",
|
|
166
|
+
"DEFAULT_SCHEME",
|
|
167
|
+
"EMPTY_CONSOLE_STATE",
|
|
168
|
+
"Handled",
|
|
169
|
+
"InkThemeAdapter",
|
|
170
|
+
"MIDNIGHT_CB_PALETTE",
|
|
171
|
+
"MIDNIGHT_PALETTE",
|
|
172
|
+
"MODAL_KINDS",
|
|
173
|
+
"ModalClose",
|
|
174
|
+
"ModalKind",
|
|
175
|
+
"ModalOpen",
|
|
176
|
+
"ModalState",
|
|
177
|
+
"NO_MODAL",
|
|
178
|
+
"OpenModal",
|
|
179
|
+
"OverlayServices",
|
|
180
|
+
"PALETTES",
|
|
181
|
+
"Prompt",
|
|
182
|
+
"RowsAppend",
|
|
183
|
+
"RowsPatch",
|
|
184
|
+
"RowsSet",
|
|
185
|
+
"SchemeSet",
|
|
186
|
+
"SessionConductor",
|
|
187
|
+
"SessionSignal",
|
|
188
|
+
"SessionSnapshot",
|
|
189
|
+
"SlashCommand",
|
|
190
|
+
"SlashContext",
|
|
191
|
+
"SlashOutcome",
|
|
192
|
+
"SlashRegistry",
|
|
193
|
+
"SlashRun",
|
|
194
|
+
"StartOAuthLogin",
|
|
195
|
+
"StartupChangelog",
|
|
196
|
+
"StartupChangelogMode",
|
|
197
|
+
"StartupInputs",
|
|
198
|
+
"StartupMap",
|
|
199
|
+
"StartupNotice",
|
|
200
|
+
"StartupNoticeKind",
|
|
201
|
+
"StartupSection",
|
|
202
|
+
"StatusClear",
|
|
203
|
+
"StatusMessage",
|
|
204
|
+
"StatusSet",
|
|
205
|
+
"THEMES",
|
|
206
|
+
"THEME_SCHEMES",
|
|
207
|
+
"THEME_TOKEN_ROLES",
|
|
208
|
+
"ThemePalette",
|
|
209
|
+
"ThemeScheme",
|
|
210
|
+
"ThemeToken",
|
|
211
|
+
"ThemeTokens",
|
|
212
|
+
"Tick",
|
|
213
|
+
"ToggleImages",
|
|
214
|
+
"ToggleReasoning",
|
|
215
|
+
"ToolExecutionState",
|
|
216
|
+
"UiDisplayBlock",
|
|
217
|
+
"Unknown",
|
|
218
|
+
"ViewRow",
|
|
219
|
+
"ViewRowKind",
|
|
220
|
+
"console_reducer",
|
|
221
|
+
"count_providers",
|
|
222
|
+
"derive_tokens",
|
|
223
|
+
"framework_colors",
|
|
224
|
+
"gather_changelog",
|
|
225
|
+
"gather_startup",
|
|
226
|
+
"init_console_state",
|
|
227
|
+
"is_theme_scheme",
|
|
228
|
+
"mount_console",
|
|
229
|
+
"project_snapshot",
|
|
230
|
+
"read_branch",
|
|
231
|
+
"read_double_escape_action",
|
|
232
|
+
"resolve_theme",
|
|
233
|
+
"theme_adapter",
|
|
234
|
+
"theme_bundle",
|
|
235
|
+
"transition_modal",
|
|
236
|
+
]
|