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,350 @@
|
|
|
1
|
+
"""SessionLibrary — the catalog-and-navigation layer over persisted
|
|
2
|
+
transcripts (port of TS ``src/sessions/library.ts``).
|
|
3
|
+
|
|
4
|
+
The conductor owns a single session at a time as a
|
|
5
|
+
:class:`~induscode.conductor.transcript_store.TranscriptStore`. The library
|
|
6
|
+
owns the *collection*: it scans the workspace ``sessions/`` directory for the
|
|
7
|
+
NDJSON files the conductor's filesystem backend writes, lists them as
|
|
8
|
+
:class:`~induscode.sessions.contract.SavedSession` catalog rows, opens any
|
|
9
|
+
one back into a live store via the store's own opener +
|
|
10
|
+
:func:`~induscode.conductor.transcript_store.replay` reducer, and offers the
|
|
11
|
+
two read projections a chooser UI needs — a flattened
|
|
12
|
+
:class:`~induscode.sessions.contract.BranchNode` tree for a branch navigator
|
|
13
|
+
and a :class:`~induscode.sessions.contract.PriorTurn` list for re-asking an
|
|
14
|
+
earlier prompt. It also performs the two file-level mutations a manager
|
|
15
|
+
needs: renaming and deleting a session on disk.
|
|
16
|
+
|
|
17
|
+
The library never re-implements transcript parsing or message serialization —
|
|
18
|
+
it delegates entirely to the conductor's transcript-store surface
|
|
19
|
+
(:class:`TranscriptStore`, :func:`fs_backend`, :func:`replay`). Its own logic
|
|
20
|
+
is purely the directory enumeration, the tree flatten, and the text
|
|
21
|
+
reduction.
|
|
22
|
+
|
|
23
|
+
Seam note: the per-cwd session scope directory (the ``--<cwd slug>--`` layout
|
|
24
|
+
under the workspace ``sessions/`` root) is **boot's** helper — TS
|
|
25
|
+
``boot/runners/session.ts#sessionScopeDir`` — not the library's. The library
|
|
26
|
+
takes the already-scoped directory; the M4 boot wave ports the slug helper
|
|
27
|
+
and must agree with the conductor (writer) on it.
|
|
28
|
+
|
|
29
|
+
Port note: disk I/O runs through :func:`asyncio.to_thread` (matching the
|
|
30
|
+
conductor's ``fs_backend`` discipline) so the async surface never blocks the
|
|
31
|
+
loop.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Mapping, Sequence
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Final
|
|
42
|
+
|
|
43
|
+
from induscode.conductor.contract import TranscriptEntry
|
|
44
|
+
from induscode.conductor.transcript_store import TranscriptStore, fs_backend
|
|
45
|
+
from induscode.sessions.contract import BranchNode, PriorTurn, SavedSession
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"SessionLibrary",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Suffix the conductor's filesystem backend gives every session file.
|
|
53
|
+
_SESSION_FILE_EXT: Final[str] = ".ndjson"
|
|
54
|
+
|
|
55
|
+
#: Default cap for the single-line previews this library renders.
|
|
56
|
+
_PREVIEW_LIMIT: Final[int] = 72
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionLibrary:
|
|
60
|
+
"""The collection-level handle over a workspace's persisted sessions.
|
|
61
|
+
|
|
62
|
+
Construct it with the resolved ``sessions_dir`` (from the workspace
|
|
63
|
+
locator, already cwd-scoped by boot), then call :meth:`list` /
|
|
64
|
+
:meth:`open` / :meth:`rename` / :meth:`remove` to manage files, and
|
|
65
|
+
:meth:`tree` / :meth:`prior_turns` to project a loaded transcript for
|
|
66
|
+
navigation and forking.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("_dir", "_backend")
|
|
70
|
+
|
|
71
|
+
def __init__(self, *, sessions_dir: str) -> None:
|
|
72
|
+
""":param sessions_dir: absolute path to the workspace ``sessions/``
|
|
73
|
+
directory (TS ``SessionLibraryOptions.sessionsDir``)."""
|
|
74
|
+
self._dir = sessions_dir
|
|
75
|
+
self._backend = fs_backend(sessions_dir)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def directory(self) -> str:
|
|
79
|
+
"""The sessions directory this library is rooted at."""
|
|
80
|
+
return self._dir
|
|
81
|
+
|
|
82
|
+
async def list(self, *, deep: bool = False) -> list[SavedSession]:
|
|
83
|
+
"""Enumerate persisted sessions as catalog rows, newest-modified
|
|
84
|
+
first.
|
|
85
|
+
|
|
86
|
+
Scans the directory for ``*.ndjson`` files and stats each for its
|
|
87
|
+
size and modification time. When ``deep`` is set, it also opens each
|
|
88
|
+
file to fill in ``messageCount`` and the opening-turn ``preview``;
|
|
89
|
+
otherwise it returns the shallow rows (id/path/size/lastModified) so
|
|
90
|
+
a large directory lists fast. A missing directory yields an empty
|
|
91
|
+
list rather than an error.
|
|
92
|
+
"""
|
|
93
|
+
ids = await self._session_ids()
|
|
94
|
+
rows = await asyncio.gather(
|
|
95
|
+
*[self._deep_row(id) if deep else self._shallow_row(id) for id in ids]
|
|
96
|
+
)
|
|
97
|
+
return sorted(rows, key=_most_recent_key)
|
|
98
|
+
|
|
99
|
+
async def open(self, id: str) -> TranscriptStore | None:
|
|
100
|
+
"""Open a persisted session back into a live, hydrated
|
|
101
|
+
:class:`TranscriptStore`, or return ``None`` when no file with that
|
|
102
|
+
id exists.
|
|
103
|
+
|
|
104
|
+
Delegates to the store's own opener, which reads the backend and
|
|
105
|
+
rebuilds state through the ``replay`` reducer — the library adds
|
|
106
|
+
nothing to the rehydration beyond binding the filesystem backend.
|
|
107
|
+
"""
|
|
108
|
+
return await TranscriptStore.open(id, backend=self._backend)
|
|
109
|
+
|
|
110
|
+
async def rename(self, from_id: str, to_id: str) -> str:
|
|
111
|
+
"""Rename a session file from one id to another, returning the new
|
|
112
|
+
absolute path. The on-disk identifier *is* the filename stem, so this
|
|
113
|
+
is a plain file move; the in-file head still names the old id, which
|
|
114
|
+
the store tolerates on the next load (the head's leaf is what
|
|
115
|
+
matters, not its session label).
|
|
116
|
+
|
|
117
|
+
:raises ValueError: if the source file is missing or the target id
|
|
118
|
+
already exists.
|
|
119
|
+
"""
|
|
120
|
+
from_path = self.path_of(from_id)
|
|
121
|
+
to_path = self.path_of(to_id)
|
|
122
|
+
if await self._missing(from_path):
|
|
123
|
+
raise ValueError(f'SessionLibrary.rename: no session "{from_id}"')
|
|
124
|
+
if not await self._missing(to_path):
|
|
125
|
+
raise ValueError(f'SessionLibrary.rename: session "{to_id}" already exists')
|
|
126
|
+
|
|
127
|
+
def _move() -> None:
|
|
128
|
+
Path(self._dir).mkdir(parents=True, exist_ok=True)
|
|
129
|
+
os.rename(from_path, to_path)
|
|
130
|
+
|
|
131
|
+
await asyncio.to_thread(_move)
|
|
132
|
+
return to_path
|
|
133
|
+
|
|
134
|
+
async def remove(self, id: str) -> bool:
|
|
135
|
+
"""Delete a session file. Returns ``True`` when a file was removed,
|
|
136
|
+
``False`` when there was nothing to delete — never raises on an
|
|
137
|
+
already-absent session."""
|
|
138
|
+
path = self.path_of(id)
|
|
139
|
+
if await self._missing(path):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def _unlink() -> None:
|
|
143
|
+
Path(path).unlink(missing_ok=True)
|
|
144
|
+
|
|
145
|
+
await asyncio.to_thread(_unlink)
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def path_of(self, id: str) -> str:
|
|
149
|
+
"""Absolute path a session id persists to."""
|
|
150
|
+
return str(Path(self._dir) / f"{id}{_SESSION_FILE_EXT}")
|
|
151
|
+
|
|
152
|
+
def tree(self, store: TranscriptStore) -> list[BranchNode]:
|
|
153
|
+
"""Flatten a loaded transcript into an ordered :class:`BranchNode`
|
|
154
|
+
list for a tree navigator.
|
|
155
|
+
|
|
156
|
+
Walks every node from each root downward in a stable depth-first
|
|
157
|
+
order, emitting one row per node with its render label, depth
|
|
158
|
+
indentation, leaf flag, and a current-marker derived from the store's
|
|
159
|
+
active head. A node is a leaf when no other node names it as
|
|
160
|
+
``parent``. Pure — no I/O.
|
|
161
|
+
"""
|
|
162
|
+
nodes = list(store.state().nodes.values())
|
|
163
|
+
current_leaf = store.head.leaf
|
|
164
|
+
|
|
165
|
+
children_of: dict[str | None, list[TranscriptEntry]] = {}
|
|
166
|
+
for node in nodes:
|
|
167
|
+
children_of.setdefault(node.parent, []).append(node)
|
|
168
|
+
has_children: set[str] = {node.parent for node in nodes if node.parent is not None}
|
|
169
|
+
|
|
170
|
+
out: list[BranchNode] = []
|
|
171
|
+
|
|
172
|
+
def walk(entry: TranscriptEntry, depth: int) -> None:
|
|
173
|
+
out.append(
|
|
174
|
+
BranchNode(
|
|
175
|
+
id=entry.id,
|
|
176
|
+
parent=entry.parent,
|
|
177
|
+
label=_label_for(entry, depth),
|
|
178
|
+
depth=depth,
|
|
179
|
+
isLeaf=entry.id not in has_children,
|
|
180
|
+
isCurrent=entry.id == current_leaf,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
for child in children_of.get(entry.id, []):
|
|
184
|
+
walk(child, depth + 1)
|
|
185
|
+
|
|
186
|
+
for root in children_of.get(None, []):
|
|
187
|
+
walk(root, 0)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
def prior_turns(self, store: TranscriptStore) -> list[PriorTurn]:
|
|
191
|
+
"""Extract the user prompts along a loaded transcript's active branch
|
|
192
|
+
as fork candidates, in chronological order.
|
|
193
|
+
|
|
194
|
+
Reads the root→leaf branch the store currently points at (the
|
|
195
|
+
conversation the agent would replay) and keeps only the user-role
|
|
196
|
+
nodes that carry real text, projecting each to a :class:`PriorTurn` a
|
|
197
|
+
forking picker can offer. Pure — no I/O.
|
|
198
|
+
"""
|
|
199
|
+
out: list[PriorTurn] = []
|
|
200
|
+
for entry in store.path_to():
|
|
201
|
+
if entry.role != "user":
|
|
202
|
+
continue
|
|
203
|
+
text = _message_text(entry.content).strip()
|
|
204
|
+
if len(text) == 0:
|
|
205
|
+
continue
|
|
206
|
+
out.append(PriorTurn(entryId=entry.id, text=text, preview=_preview_of(text)))
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
# -- internals ------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async def _session_ids(self) -> list[str]:
|
|
212
|
+
"""Bare session ids present in the directory (no extension), or
|
|
213
|
+
``[]``."""
|
|
214
|
+
|
|
215
|
+
def _scan() -> list[str]:
|
|
216
|
+
try:
|
|
217
|
+
names = os.listdir(self._dir)
|
|
218
|
+
except OSError:
|
|
219
|
+
return []
|
|
220
|
+
return [
|
|
221
|
+
name[: -len(_SESSION_FILE_EXT)]
|
|
222
|
+
for name in names
|
|
223
|
+
if name.endswith(_SESSION_FILE_EXT)
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
return await asyncio.to_thread(_scan)
|
|
227
|
+
|
|
228
|
+
async def _shallow_row(self, id: str) -> SavedSession:
|
|
229
|
+
"""Catalog row from file metadata only — no parse."""
|
|
230
|
+
path = self.path_of(id)
|
|
231
|
+
size, last_modified = await self._file_meta(path)
|
|
232
|
+
return SavedSession(id=id, path=path, size=size, lastModified=last_modified)
|
|
233
|
+
|
|
234
|
+
async def _deep_row(self, id: str) -> SavedSession:
|
|
235
|
+
"""Catalog row enriched by opening the transcript and reducing its
|
|
236
|
+
text."""
|
|
237
|
+
path = self.path_of(id)
|
|
238
|
+
size, last_modified = await self._file_meta(path)
|
|
239
|
+
store = await self.open(id)
|
|
240
|
+
if store is None:
|
|
241
|
+
return SavedSession(id=id, path=path, size=size, lastModified=last_modified)
|
|
242
|
+
|
|
243
|
+
branch = store.path_to()
|
|
244
|
+
conversational = [entry for entry in branch if _is_conversational(entry)]
|
|
245
|
+
opener = next((entry for entry in conversational if entry.role == "user"), None)
|
|
246
|
+
preview = _preview_of(_message_text(opener.content)) if opener is not None else None
|
|
247
|
+
has_preview = preview is not None and len(preview) > 0
|
|
248
|
+
return SavedSession(
|
|
249
|
+
id=id,
|
|
250
|
+
path=path,
|
|
251
|
+
size=size,
|
|
252
|
+
lastModified=last_modified,
|
|
253
|
+
messageCount=len(conversational),
|
|
254
|
+
preview=preview if has_preview else None,
|
|
255
|
+
name=preview if has_preview else None,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def _file_meta(self, path: str) -> tuple[int | None, float | None]:
|
|
259
|
+
"""Best-effort ``(size, lastModified-ms)``; ``(None, None)`` when the
|
|
260
|
+
file is gone."""
|
|
261
|
+
|
|
262
|
+
def _stat() -> tuple[int | None, float | None]:
|
|
263
|
+
try:
|
|
264
|
+
info = os.stat(path)
|
|
265
|
+
return info.st_size, info.st_mtime * 1000.0
|
|
266
|
+
except OSError:
|
|
267
|
+
return None, None
|
|
268
|
+
|
|
269
|
+
return await asyncio.to_thread(_stat)
|
|
270
|
+
|
|
271
|
+
async def _missing(self, path: str) -> bool:
|
|
272
|
+
"""True when a path does not resolve to a readable file."""
|
|
273
|
+
return not await asyncio.to_thread(os.path.isfile, path)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Pure helpers
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _is_conversational(entry: TranscriptEntry) -> bool:
|
|
282
|
+
"""Conversational roles count toward a session's message total."""
|
|
283
|
+
return entry.role in ("user", "assistant", "tool")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _most_recent_key(row: SavedSession) -> tuple[float, str]:
|
|
287
|
+
"""Sort key: most recently modified first, ties broken by id."""
|
|
288
|
+
return (-(row.lastModified if row.lastModified is not None else 0), row.id)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _label_for(entry: TranscriptEntry, depth: int) -> str:
|
|
292
|
+
"""Build a depth-indented, role-aware label for a transcript node."""
|
|
293
|
+
indent = " " * depth
|
|
294
|
+
match entry.role:
|
|
295
|
+
case "user":
|
|
296
|
+
return f"{indent}user: {_preview_of(_message_text(entry.content))}"
|
|
297
|
+
case "assistant":
|
|
298
|
+
return f"{indent}assistant"
|
|
299
|
+
case "tool":
|
|
300
|
+
return f"{indent}tool: {_tool_name_of(entry.content)}"
|
|
301
|
+
case "condense":
|
|
302
|
+
return f"{indent}condense"
|
|
303
|
+
case "system":
|
|
304
|
+
return f"{indent}system"
|
|
305
|
+
case _:
|
|
306
|
+
return f"{indent}note"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _read(value: Any, name: str) -> Any:
|
|
310
|
+
"""Field access tolerant of both frozen dataclasses and raw mappings."""
|
|
311
|
+
if isinstance(value, Mapping):
|
|
312
|
+
return value.get(name)
|
|
313
|
+
return getattr(value, name, None)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _tool_name_of(message: Any) -> str:
|
|
317
|
+
"""Tool name carried by a tool-result message, or a placeholder."""
|
|
318
|
+
candidate = _read(message, "toolName")
|
|
319
|
+
return candidate if isinstance(candidate, str) and len(candidate) > 0 else "tool"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _message_text(message: Any) -> str:
|
|
323
|
+
"""Reduce any framework ``AgentMessage`` to its plain text.
|
|
324
|
+
|
|
325
|
+
Messages carry ``content`` as either a raw string or a sequence of typed
|
|
326
|
+
blocks; we concatenate the text-bearing blocks and ignore images,
|
|
327
|
+
thinking, and tool calls. Defensive about shape so a malformed or custom
|
|
328
|
+
message yields ``""`` rather than raising.
|
|
329
|
+
"""
|
|
330
|
+
content = _read(message, "content")
|
|
331
|
+
if isinstance(content, str):
|
|
332
|
+
return content
|
|
333
|
+
if not isinstance(content, Sequence) or isinstance(content, (str, bytes)):
|
|
334
|
+
return ""
|
|
335
|
+
parts: list[str] = []
|
|
336
|
+
for block in content:
|
|
337
|
+
if _read(block, "type") == "text":
|
|
338
|
+
text = _read(block, "text")
|
|
339
|
+
if isinstance(text, str):
|
|
340
|
+
parts.append(text)
|
|
341
|
+
return "".join(parts)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _preview_of(text: str, limit: int = _PREVIEW_LIMIT) -> str:
|
|
345
|
+
"""Collapse text to a single trimmed line and cap its length for
|
|
346
|
+
display."""
|
|
347
|
+
flat = re.sub(r"\s+", " ", text).strip()
|
|
348
|
+
if len(flat) <= limit:
|
|
349
|
+
return flat
|
|
350
|
+
return flat[: max(0, limit - 1)] + "…"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Settings subsystem — public barrel (port of the TS ``src/settings``
|
|
2
|
+
barrel).
|
|
3
|
+
|
|
4
|
+
One import site for the typed :class:`Preferences` record and its
|
|
5
|
+
:data:`SettingKey` / :data:`EscapeAction` / :data:`DeliveryMode` /
|
|
6
|
+
``ThinkingLevel`` vocabularies, the frozen :data:`DEFAULT_PREFERENCES`, the
|
|
7
|
+
explicit snake_case ↔ camelCase alias maps, and the two-tier
|
|
8
|
+
:class:`PreferenceStore` reader/writer. Console surfaces and boot stages
|
|
9
|
+
depend on this module rather than reaching into the individual files.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .contract import (
|
|
13
|
+
DEFAULT_PREFERENCES,
|
|
14
|
+
DELIVERY_MODES,
|
|
15
|
+
ESCAPE_ACTIONS,
|
|
16
|
+
FIELD_TO_JSON_KEY,
|
|
17
|
+
JSON_TO_FIELD_KEY,
|
|
18
|
+
SETTING_KEYS,
|
|
19
|
+
DeliveryMode,
|
|
20
|
+
EscapeAction,
|
|
21
|
+
Preferences,
|
|
22
|
+
SettingKey,
|
|
23
|
+
ThinkingLevel,
|
|
24
|
+
canonical_key,
|
|
25
|
+
is_delivery_mode,
|
|
26
|
+
is_escape_action,
|
|
27
|
+
)
|
|
28
|
+
from .manager import PreferenceLocations, PreferenceStore
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_PREFERENCES",
|
|
32
|
+
"DELIVERY_MODES",
|
|
33
|
+
"DeliveryMode",
|
|
34
|
+
"ESCAPE_ACTIONS",
|
|
35
|
+
"EscapeAction",
|
|
36
|
+
"FIELD_TO_JSON_KEY",
|
|
37
|
+
"JSON_TO_FIELD_KEY",
|
|
38
|
+
"PreferenceLocations",
|
|
39
|
+
"PreferenceStore",
|
|
40
|
+
"Preferences",
|
|
41
|
+
"SETTING_KEYS",
|
|
42
|
+
"SettingKey",
|
|
43
|
+
"ThinkingLevel",
|
|
44
|
+
"canonical_key",
|
|
45
|
+
"is_delivery_mode",
|
|
46
|
+
"is_escape_action",
|
|
47
|
+
]
|