induscode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,313 @@
1
+ """Settings contract — the typed surface of the user-tunable preferences an
2
+ interactive coding-agent session reads at startup and while it runs.
3
+
4
+ This module declares *only* shapes and frozen defaults — no I/O, no store, no
5
+ filesystem. It is the single place every preference key is named, typed, and
6
+ given a fallback. The two-tier store (:mod:`.manager`) is written against the
7
+ :class:`Preferences` record declared here, and any console surface that
8
+ toggles a preference reads its value through the same record.
9
+
10
+ Design stance
11
+ -------------
12
+ - Every field is optional (``None`` plays the TS ``undefined`` "key not
13
+ present" role — no preference legally holds null as a real value) so a
14
+ partially-written file is a legal :class:`Preferences` value; the store
15
+ fills the gaps from :data:`DEFAULT_PREFERENCES` when a reader asks for a
16
+ concrete value.
17
+ - Reasoning-effort vocabulary is borrowed from the framework
18
+ (``indusagi.agent.ThinkingLevel``) rather than re-declared, so the agent
19
+ and the UI speak the same words.
20
+ - The other small string unions (the colour scheme is a free string, while
21
+ :data:`EscapeAction` / :data:`DeliveryMode` are pinned) name the surfaces a
22
+ console reads.
23
+
24
+ Port notes (TS ``src/settings/contract.ts``)
25
+ --------------------------------------------
26
+ - The TS ``Preferences`` interface (23 optional camelCase keys) becomes a
27
+ frozen dataclass with snake_case fields. The on-disk JSON keeps the TS
28
+ camelCase spelling verbatim — a settings file written by the TS agent reads
29
+ back unchanged — and the explicit :data:`FIELD_TO_JSON_KEY` /
30
+ :data:`JSON_TO_FIELD_KEY` maps are the single place the two spellings are
31
+ tied together (no mechanical case converter to silently drift).
32
+ - List-typed values are tuples so :data:`DEFAULT_PREFERENCES` is deeply
33
+ immutable and safe to share without a defensive copy (the TS record relied
34
+ on ``Object.freeze`` plus discipline).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass, fields
40
+ from types import MappingProxyType
41
+ from typing import Any, Final, Literal, TypeAlias, TypeGuard, get_args
42
+
43
+ from indusagi.agent import ThinkingLevel
44
+
45
+ __all__ = [
46
+ "DEFAULT_PREFERENCES",
47
+ "DELIVERY_MODES",
48
+ "DeliveryMode",
49
+ "ESCAPE_ACTIONS",
50
+ "EscapeAction",
51
+ "FIELD_TO_JSON_KEY",
52
+ "JSON_TO_FIELD_KEY",
53
+ "Preferences",
54
+ "SETTING_KEYS",
55
+ "SettingKey",
56
+ "ThinkingLevel",
57
+ "canonical_key",
58
+ "is_delivery_mode",
59
+ "is_escape_action",
60
+ ]
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Narrow vocabularies
65
+ # ---------------------------------------------------------------------------
66
+
67
+ #: What a double-tap of the escape key does in the interactive console.
68
+ #:
69
+ #: - ``tree`` — open the turn history as a navigable tree of branches.
70
+ #: - ``fork`` — open the prior-turn picker to branch off an earlier turn.
71
+ #: - ``clear`` — wipe the composer buffer.
72
+ #:
73
+ #: The legacy ``rewind`` / ``branch`` aliases are still accepted and treated
74
+ #: as ``fork`` / ``tree`` respectively, so an older preference file keeps
75
+ #: working. They are part of the type but not of :data:`ESCAPE_ACTIONS`.
76
+ EscapeAction: TypeAlias = Literal["tree", "fork", "clear", "rewind", "branch"]
77
+
78
+ #: Every canonical :data:`EscapeAction` value (legacy aliases excluded), as a
79
+ #: frozen tuple for menus and guards.
80
+ ESCAPE_ACTIONS: Final[tuple[EscapeAction, ...]] = ("tree", "fork", "clear")
81
+
82
+
83
+ def is_escape_action(value: object) -> TypeGuard[EscapeAction]:
84
+ """Narrow an arbitrary value to a known (canonical) :data:`EscapeAction`."""
85
+ return isinstance(value, str) and value in ESCAPE_ACTIONS
86
+
87
+
88
+ #: How a queue of pending turns (steering corrections or follow-up prompts)
89
+ #: is drained back into the run.
90
+ #:
91
+ #: - ``all`` — release every queued turn at once on the next handoff.
92
+ #: - ``one-at-a-time`` — release a single queued turn per handoff.
93
+ DeliveryMode: TypeAlias = Literal["all", "one-at-a-time"]
94
+
95
+ #: Every :data:`DeliveryMode` value, as a frozen tuple for menus and guards.
96
+ DELIVERY_MODES: Final[tuple[DeliveryMode, ...]] = ("all", "one-at-a-time")
97
+
98
+
99
+ def is_delivery_mode(value: object) -> TypeGuard[DeliveryMode]:
100
+ """Narrow an arbitrary value to a known :data:`DeliveryMode`."""
101
+ return isinstance(value, str) and value in DELIVERY_MODES
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Preference record
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class Preferences:
111
+ """Every preference an interactive coding-agent session reads, as one
112
+ flat, fully-optional record.
113
+
114
+ A value of this type may be all-``None`` (a brand-new install), a sparse
115
+ project override, or a fully-populated snapshot — all three are legal.
116
+ Concrete reads always go through the store, which layers the project file
117
+ over the global file over :data:`DEFAULT_PREFERENCES`, so a reader never
118
+ sees ``None`` for a key with a default.
119
+ """
120
+
121
+ # Named colour scheme the console renders with (e.g. "midnight").
122
+ colour_scheme: str | None = None
123
+ # Whether inline image content is rendered in the transcript.
124
+ show_images: bool | None = None
125
+ # Whether the model's reasoning / thinking text is shown as it streams.
126
+ show_reasoning: bool | None = None
127
+ # When True, the reasoning block is folded away even if the model emits one.
128
+ hide_thinking: bool | None = None
129
+ # Whether oversized images are shrunk to fit before reaching a provider.
130
+ image_auto_resize: bool | None = None
131
+ # When True, image content is withheld from providers entirely.
132
+ block_images: bool | None = None
133
+ # Whether discovered skills are surfaced as their own slash commands.
134
+ enable_skill_commands: bool | None = None
135
+ # How queued steering corrections are released back into the run.
136
+ steering_mode: DeliveryMode | None = None
137
+ # How queued follow-up prompts are released back into the run.
138
+ follow_up_mode: DeliveryMode | None = None
139
+ # Prefer a condensed changelog after the console updates itself.
140
+ collapse_changelog: bool | None = None
141
+ # Reveal the terminal's own cursor instead of the software-drawn caret.
142
+ show_hardware_cursor: bool | None = None
143
+ # Horizontal padding, in columns, around the prompt editor.
144
+ editor_padding_x: int | None = None
145
+ # Glob / id patterns selecting picker models; empty means all.
146
+ enabled_models: tuple[str, ...] | None = None
147
+ # Provider id the session defaults to when none is named.
148
+ default_provider: str | None = None
149
+ # Model id the session opens with under ``default_provider``.
150
+ default_model: str | None = None
151
+ # Reasoning effort the session requests by default.
152
+ default_thinking_level: ThinkingLevel | None = None
153
+ # Suppress the banner / tips shown on a normal interactive launch.
154
+ quiet_startup: bool | None = None
155
+ # The last product version whose full masthead the user has already seen;
156
+ # the banner auto-condenses when this equals the running version.
157
+ last_seen_version: str | None = None
158
+ # Opt-in static colour-sweep flourish tinting the startup wordmark.
159
+ logo_sweep: bool | None = None
160
+ # When set, motion-flavoured flourishes (e.g. the logo sweep) are off.
161
+ reduced_motion: bool | None = None
162
+ # Whether the window-budget manager compacts the transcript automatically.
163
+ auto_compact: bool | None = None
164
+ # What a double-escape does in the console.
165
+ double_escape_action: EscapeAction | None = None
166
+ # Extension-package sources the launcher installs (npm / git / local).
167
+ extension_packages: tuple[str, ...] | None = None
168
+
169
+
170
+ #: The set of legal preference keys (snake_case field names), as a Literal
171
+ #: union — the Python analogue of the TS ``keyof Preferences``.
172
+ SettingKey: TypeAlias = Literal[
173
+ "colour_scheme",
174
+ "show_images",
175
+ "show_reasoning",
176
+ "hide_thinking",
177
+ "image_auto_resize",
178
+ "block_images",
179
+ "enable_skill_commands",
180
+ "steering_mode",
181
+ "follow_up_mode",
182
+ "collapse_changelog",
183
+ "show_hardware_cursor",
184
+ "editor_padding_x",
185
+ "enabled_models",
186
+ "default_provider",
187
+ "default_model",
188
+ "default_thinking_level",
189
+ "quiet_startup",
190
+ "last_seen_version",
191
+ "logo_sweep",
192
+ "reduced_motion",
193
+ "auto_compact",
194
+ "double_escape_action",
195
+ "extension_packages",
196
+ ]
197
+
198
+ #: Every preference key, as a frozen tuple for iteration and validation
199
+ #: (derived from :data:`SettingKey` so the two can never drift).
200
+ SETTING_KEYS: Final[tuple[str, ...]] = get_args(SettingKey)
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Field ↔ JSON-key alias maps
205
+ # ---------------------------------------------------------------------------
206
+
207
+ #: Explicit snake_case-field → camelCase-JSON-key map, in declaration order.
208
+ #: The on-disk spelling is the TS one, kept verbatim; this map is the single
209
+ #: tie between the two namings.
210
+ FIELD_TO_JSON_KEY: Final[MappingProxyType[str, str]] = MappingProxyType(
211
+ {
212
+ "colour_scheme": "colourScheme",
213
+ "show_images": "showImages",
214
+ "show_reasoning": "showReasoning",
215
+ "hide_thinking": "hideThinking",
216
+ "image_auto_resize": "imageAutoResize",
217
+ "block_images": "blockImages",
218
+ "enable_skill_commands": "enableSkillCommands",
219
+ "steering_mode": "steeringMode",
220
+ "follow_up_mode": "followUpMode",
221
+ "collapse_changelog": "collapseChangelog",
222
+ "show_hardware_cursor": "showHardwareCursor",
223
+ "editor_padding_x": "editorPaddingX",
224
+ "enabled_models": "enabledModels",
225
+ "default_provider": "defaultProvider",
226
+ "default_model": "defaultModel",
227
+ "default_thinking_level": "defaultThinkingLevel",
228
+ "quiet_startup": "quietStartup",
229
+ "last_seen_version": "lastSeenVersion",
230
+ "logo_sweep": "logoSweep",
231
+ "reduced_motion": "reducedMotion",
232
+ "auto_compact": "autoCompact",
233
+ "double_escape_action": "doubleEscapeAction",
234
+ "extension_packages": "extensionPackages",
235
+ }
236
+ )
237
+
238
+ #: The reverse map: camelCase JSON key → snake_case field name.
239
+ JSON_TO_FIELD_KEY: Final[MappingProxyType[str, str]] = MappingProxyType(
240
+ {json_key: field for field, json_key in FIELD_TO_JSON_KEY.items()}
241
+ )
242
+
243
+
244
+ def canonical_key(key: str) -> str:
245
+ """Normalise a preference key to its canonical snake_case field name.
246
+
247
+ Accepts either the snake_case field name (the Python surface) or the
248
+ camelCase JSON spelling (the on-disk surface). Raises :class:`KeyError`
249
+ for anything else, so a typo is a loud error rather than a silent miss.
250
+ """
251
+ if key in FIELD_TO_JSON_KEY:
252
+ return key
253
+ field = JSON_TO_FIELD_KEY.get(key)
254
+ if field is not None:
255
+ return field
256
+ raise KeyError(f"unknown preference key: {key!r}")
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Defaults
261
+ # ---------------------------------------------------------------------------
262
+
263
+ #: The fallback value for every preference, used whenever neither the project
264
+ #: nor the global tier supplies a key.
265
+ #:
266
+ #: Frozen (and tuple-valued where the TS used arrays) so it can be shared
267
+ #: without a defensive copy. Every field carries a concrete (non-``None``)
268
+ #: value — the analogue of the TS ``Required<Preferences>`` — which is what
269
+ #: lets a typed reader resolve a guaranteed result. Values are the TS
270
+ #: fallbacks, verbatim.
271
+ DEFAULT_PREFERENCES: Final[Preferences] = Preferences(
272
+ colour_scheme="default",
273
+ show_images=True,
274
+ show_reasoning=True,
275
+ hide_thinking=False,
276
+ image_auto_resize=True,
277
+ block_images=False,
278
+ enable_skill_commands=True,
279
+ steering_mode="all",
280
+ follow_up_mode="all",
281
+ collapse_changelog=False,
282
+ show_hardware_cursor=False,
283
+ editor_padding_x=1,
284
+ enabled_models=(),
285
+ default_provider="anthropic",
286
+ default_model="",
287
+ default_thinking_level="medium",
288
+ quiet_startup=False,
289
+ last_seen_version="",
290
+ logo_sweep=False,
291
+ reduced_motion=False,
292
+ auto_compact=True,
293
+ double_escape_action="clear",
294
+ extension_packages=(),
295
+ )
296
+
297
+
298
+ def _default_value(field: str) -> Any:
299
+ """The concrete default for one canonical field name."""
300
+ return getattr(DEFAULT_PREFERENCES, field)
301
+
302
+
303
+ # The dataclass, the SettingKey union, and the alias map must always describe
304
+ # the same 23-key set, and the defaults must be fully populated; assert it
305
+ # once at import so a drift is an immediate, loud error (the Python analogue
306
+ # of the TS `Required<Preferences>` / `keyof` constraints).
307
+ _FIELD_NAMES = {field.name for field in fields(Preferences)}
308
+ assert set(SETTING_KEYS) == _FIELD_NAMES, "SettingKey must match Preferences fields"
309
+ assert set(FIELD_TO_JSON_KEY) == _FIELD_NAMES, "alias map must cover Preferences fields"
310
+ assert len(JSON_TO_FIELD_KEY) == len(FIELD_TO_JSON_KEY), "alias map must be one-to-one"
311
+ assert all(
312
+ _default_value(name) is not None for name in SETTING_KEYS
313
+ ), "DEFAULT_PREFERENCES must populate every key"
@@ -0,0 +1,268 @@
1
+ """Settings manager — the two-tier reader/writer over the preference record.
2
+
3
+ Two files back the same :class:`~.contract.Preferences` shape:
4
+
5
+ - the **global** file, one per machine, living in the resolved workspace
6
+ profile directory (``Workspace.settings_path`` — ``<profile_root>/settings.json``);
7
+ - the **project** file, one per checkout, living beside the working tree in
8
+ the brand-named config directory (``<cwd>/.pindusagi/settings.json``).
9
+
10
+ A read layers the project tier over the global tier over
11
+ :data:`~.contract.DEFAULT_PREFERENCES`: a key set in the project file wins,
12
+ then the global file, then the built-in default — so a reader of
13
+ :meth:`PreferenceStore.get` always receives a concrete value. A write through
14
+ :meth:`PreferenceStore.set` lands in the **project** tier only; the global
15
+ file is left untouched, which keeps a per-checkout override from silently
16
+ leaking machine-wide.
17
+
18
+ Loading is tolerant: a missing file is the empty record, and a corrupt file
19
+ (bad JSON, or a JSON value that is not an object) degrades to the empty
20
+ record rather than raising, so one mangled file never blocks startup.
21
+
22
+ Construction is injectable. :meth:`PreferenceStore.from_workspace` resolves
23
+ the two paths from a :class:`~induscode.workspace.Workspace` and the working
24
+ directory; :meth:`PreferenceStore.at_paths` pins both paths directly, which
25
+ is what the tests drive against a temp dir so the real home is never read or
26
+ written.
27
+
28
+ Port notes (TS ``src/settings/manager.ts``)
29
+ -------------------------------------------
30
+ - The on-disk JSON keys are the TS camelCase spellings; tiers are held in
31
+ memory under the canonical snake_case field names and translated through
32
+ the contract's explicit alias maps on load and save.
33
+ - JSON ``null`` is treated as "key not present" (the closest analogue of the
34
+ TS ``undefined`` hole), and clearing a staged override is done by setting
35
+ it to ``None`` (TS: ``undefined``).
36
+ - Unknown JSON keys are preserved verbatim through a load → save round-trip
37
+ (as the TS shallow-copy semantics did), but never surface through
38
+ :meth:`PreferenceStore.get` / :meth:`PreferenceStore.snapshot`.
39
+ - JSON arrays are coerced to tuples on load so tier values compare equal to
40
+ the tuple-valued defaults.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import os
47
+ from dataclasses import dataclass
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from ..workspace import BRAND, Workspace
52
+
53
+ from .contract import (
54
+ DEFAULT_PREFERENCES,
55
+ FIELD_TO_JSON_KEY,
56
+ SETTING_KEYS,
57
+ Preferences,
58
+ canonical_key,
59
+ )
60
+
61
+ __all__ = [
62
+ "PreferenceLocations",
63
+ "PreferenceStore",
64
+ ]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Locations
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class PreferenceLocations:
74
+ """The resolved location of the two preference tiers.
75
+
76
+ Both are absolute paths. Either file may be absent on disk — the store
77
+ treats a missing file as the empty record. (The TS record's ``global`` /
78
+ ``project`` member names gain a ``_path`` suffix because ``global`` is a
79
+ Python keyword.)
80
+ """
81
+
82
+ # Machine-wide preference file (the global tier).
83
+ global_path: Path
84
+ # Per-checkout preference file (the project tier).
85
+ project_path: Path
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Tolerant load / write
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _freeze(value: Any) -> Any:
94
+ """Coerce a JSON array to a tuple so tier values match the tuple-valued
95
+ defaults; everything else passes through unchanged."""
96
+ if isinstance(value, list):
97
+ return tuple(value)
98
+ return value
99
+
100
+
101
+ def _load_tier(file_path: Path) -> dict[str, Any]:
102
+ """Read one preference file, degrading to the empty record on any problem.
103
+
104
+ A path that does not exist, a file that fails to parse as JSON, or a JSON
105
+ value that is not an object all resolve to ``{}`` — never a raise. Known
106
+ keys (camelCase or snake_case) are stored under their canonical
107
+ snake_case field name; unknown keys are kept verbatim so a save does not
108
+ strip them; ``null`` values are dropped (a null is "key not present").
109
+ """
110
+ try:
111
+ raw = file_path.read_text(encoding="utf-8")
112
+ except OSError:
113
+ return {}
114
+ try:
115
+ parsed = json.loads(raw)
116
+ except ValueError:
117
+ return {}
118
+ if not isinstance(parsed, dict):
119
+ return {}
120
+ tier: dict[str, Any] = {}
121
+ for key, value in parsed.items():
122
+ if value is None:
123
+ continue
124
+ try:
125
+ tier[canonical_key(key)] = _freeze(value)
126
+ except KeyError:
127
+ tier[key] = _freeze(value)
128
+ return tier
129
+
130
+
131
+ def _write_tier(file_path: Path, tier: dict[str, Any]) -> None:
132
+ """Write one preference tier as pretty JSON (camelCase keys), creating
133
+ the parent directory if needed. Pure side effect; the caller owns the
134
+ in-memory tier."""
135
+ file_path.parent.mkdir(parents=True, exist_ok=True)
136
+ payload = {FIELD_TO_JSON_KEY.get(key, key): value for key, value in tier.items()}
137
+ file_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Two-tier store
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ class PreferenceStore:
146
+ """Two-tier preference store: project-over-global-over-default on read,
147
+ and a project-tier write on save.
148
+
149
+ Construct via :meth:`from_workspace` (resolve from the workspace + cwd)
150
+ or :meth:`at_paths` (pin both files explicitly, as the tests do).
151
+ Internally the two tiers are held in memory and refreshed from disk on
152
+ construction and on :meth:`reload`; :meth:`set` stages a change into the
153
+ in-memory project tier and :meth:`save` flushes it.
154
+ """
155
+
156
+ def __init__(self, locations: PreferenceLocations) -> None:
157
+ self._locations = locations
158
+ self._global_tier = _load_tier(locations.global_path)
159
+ self._project_tier = _load_tier(locations.project_path)
160
+
161
+ @classmethod
162
+ def at_paths(
163
+ cls,
164
+ global_path: str | os.PathLike[str],
165
+ project_path: str | os.PathLike[str],
166
+ ) -> PreferenceStore:
167
+ """Build a store whose two tiers are pinned to explicit paths.
168
+
169
+ The direct seam used by tests: point both files at a temp dir and the
170
+ real home is never touched. Neither file needs to exist yet.
171
+ """
172
+ return cls(
173
+ PreferenceLocations(
174
+ global_path=Path(global_path),
175
+ project_path=Path(project_path),
176
+ )
177
+ )
178
+
179
+ @classmethod
180
+ def from_workspace(
181
+ cls,
182
+ workspace: Workspace,
183
+ cwd: str | os.PathLike[str] | None = None,
184
+ ) -> PreferenceStore:
185
+ """Build a store from a resolved workspace (global tier) and a
186
+ working directory (project tier).
187
+
188
+ The global file is ``workspace.settings_path``; the project file is
189
+ ``<cwd>/<brand profile dir>/settings.json`` (``.pindusagi``), so a
190
+ checkout carries its own overrides next to its source. ``cwd``
191
+ defaults to the process working directory.
192
+ """
193
+ base = Path(cwd) if cwd is not None else Path.cwd()
194
+ return cls(
195
+ PreferenceLocations(
196
+ global_path=workspace.settings_path,
197
+ project_path=base / BRAND.profile_dir_name / "settings.json",
198
+ )
199
+ )
200
+
201
+ def paths(self) -> PreferenceLocations:
202
+ """The two resolved file locations this store reads and writes."""
203
+ return self._locations
204
+
205
+ def reload(self) -> None:
206
+ """Re-read both tiers from disk, discarding any unsaved staged change."""
207
+ self._global_tier = _load_tier(self._locations.global_path)
208
+ self._project_tier = _load_tier(self._locations.project_path)
209
+
210
+ def get(self, key: str) -> Any:
211
+ """Read one preference, resolved across the tiers.
212
+
213
+ Precedence is project → global → :data:`DEFAULT_PREFERENCES`: the
214
+ first tier that defines the key wins. ``key`` may be the snake_case
215
+ field name or the camelCase JSON spelling; an unknown key raises
216
+ :class:`KeyError`. The result is always a concrete value, so callers
217
+ never branch on ``None``.
218
+ """
219
+ field = canonical_key(key)
220
+ if field in self._project_tier:
221
+ return self._project_tier[field]
222
+ if field in self._global_tier:
223
+ return self._global_tier[field]
224
+ return getattr(DEFAULT_PREFERENCES, field)
225
+
226
+ def set(self, key: str, value: Any) -> None:
227
+ """Stage a preference into the in-memory **project** tier.
228
+
229
+ Does not write to disk on its own — call :meth:`save` to persist.
230
+ Setting a key to ``None`` clears the project-tier override for it,
231
+ letting the value fall back to the global tier or the default on the
232
+ next read (the TS ``undefined``-clears semantics).
233
+ """
234
+ field = canonical_key(key)
235
+ if value is None:
236
+ self._project_tier.pop(field, None)
237
+ return
238
+ self._project_tier[field] = _freeze(value)
239
+
240
+ def snapshot(self) -> Preferences:
241
+ """The fully-resolved snapshot: defaults under global under project."""
242
+ merged: dict[str, Any] = {
243
+ field: getattr(DEFAULT_PREFERENCES, field) for field in SETTING_KEYS
244
+ }
245
+ for tier in (self._global_tier, self._project_tier):
246
+ for field in SETTING_KEYS:
247
+ if field in tier:
248
+ merged[field] = tier[field]
249
+ return Preferences(**merged)
250
+
251
+ def project_overrides(self) -> dict[str, Any]:
252
+ """The raw in-memory project tier (the keys a checkout overrides),
253
+ as a copy keyed by canonical field name."""
254
+ return dict(self._project_tier)
255
+
256
+ def global_defaults(self) -> dict[str, Any]:
257
+ """The raw in-memory global tier (machine-wide keys), as a copy
258
+ keyed by canonical field name."""
259
+ return dict(self._global_tier)
260
+
261
+ def save(self) -> None:
262
+ """Persist the staged in-memory **project** tier to its file.
263
+
264
+ Writes the project tier only; the global file is never rewritten
265
+ here, so a per-checkout :meth:`set` cannot mutate machine-wide
266
+ preferences. Creates the containing directory if it is missing.
267
+ """
268
+ _write_tier(self._locations.project_path, self._project_tier)
@@ -0,0 +1,109 @@
1
+ """Transcript-export subsystem — public barrel.
2
+
3
+ The HTML transcript publisher and its supporting machinery: the table-driven
4
+ SGR painter (:func:`paint_sgr`) that turns terminal-styled output into safe
5
+ HTML spans, the :class:`ThemeBridge` that computes export colors from an
6
+ :class:`ExportTheme` via a WCAG luminance LUT, the regenerated page-shell
7
+ :data:`PAGE_SHELL` template with its ``{{TOKEN}}`` slots and :func:`fill`
8
+ helper, and :func:`publish_transcript` which renders a session transcript to
9
+ a standalone HTML document using markdown-it-py, Pygments, and the painter.
10
+
11
+ Port note: the TS build rendered with ``marked`` + ``highlight.js`` and kept
12
+ this subsystem's shared types on the briefing contract; the Python build
13
+ renders with **markdown-it-py** + **Pygments** (license notices in the
14
+ emitted page swapped accordingly) and owns its types locally in
15
+ ``transcript_export/contract.py`` (only :class:`BriefingFault` stays
16
+ briefing-owned).
17
+
18
+ Consumers import the publish surface from ``induscode.transcript_export``
19
+ rather than reaching into individual modules.
20
+ """
21
+
22
+ from .contract import (
23
+ FALLBACK_EXPORT_THEME,
24
+ SGR_INITIAL_STATE,
25
+ SHELL_SLOTS,
26
+ BriefingFault,
27
+ BriefingFaultKind,
28
+ ExportTheme,
29
+ ImageContent,
30
+ LuminanceLut,
31
+ MessagePart,
32
+ PublishEntry,
33
+ PublishMessage,
34
+ PublishOptions,
35
+ PublishRole,
36
+ Rgb,
37
+ SgrCommandToken,
38
+ SgrMutation,
39
+ SgrState,
40
+ SgrTextToken,
41
+ SgrToken,
42
+ ShellSlot,
43
+ TextContent,
44
+ ThemeBridge,
45
+ ThemeMode,
46
+ ThinkingPart,
47
+ ToolCallPart,
48
+ TranscriptPart,
49
+ WidgetRender,
50
+ briefing_fault,
51
+ )
52
+ from .publish import HIGHLIGHT_LICENSE, MARKDOWN_LICENSE, publish_transcript
53
+ from .sgr import SGR_CODE_TABLE, fold_sgr, paint_sgr, tokenize_sgr
54
+ from .template import CLIENT_SCRIPT, PAGE_SHELL, PAGE_STYLES, SlotValues, fill
55
+ from .theme_bridge import (
56
+ DefaultThemeBridge,
57
+ build_luminance_lut,
58
+ create_theme_bridge,
59
+ format_color,
60
+ parse_color,
61
+ )
62
+
63
+ __all__ = [
64
+ "BriefingFault",
65
+ "BriefingFaultKind",
66
+ "CLIENT_SCRIPT",
67
+ "DefaultThemeBridge",
68
+ "ExportTheme",
69
+ "FALLBACK_EXPORT_THEME",
70
+ "HIGHLIGHT_LICENSE",
71
+ "ImageContent",
72
+ "LuminanceLut",
73
+ "MARKDOWN_LICENSE",
74
+ "MessagePart",
75
+ "PAGE_SHELL",
76
+ "PAGE_STYLES",
77
+ "PublishEntry",
78
+ "PublishMessage",
79
+ "PublishOptions",
80
+ "PublishRole",
81
+ "Rgb",
82
+ "SGR_CODE_TABLE",
83
+ "SGR_INITIAL_STATE",
84
+ "SHELL_SLOTS",
85
+ "SgrCommandToken",
86
+ "SgrMutation",
87
+ "SgrState",
88
+ "SgrTextToken",
89
+ "SgrToken",
90
+ "ShellSlot",
91
+ "SlotValues",
92
+ "TextContent",
93
+ "ThemeBridge",
94
+ "ThemeMode",
95
+ "ThinkingPart",
96
+ "ToolCallPart",
97
+ "TranscriptPart",
98
+ "WidgetRender",
99
+ "briefing_fault",
100
+ "build_luminance_lut",
101
+ "create_theme_bridge",
102
+ "fill",
103
+ "fold_sgr",
104
+ "format_color",
105
+ "paint_sgr",
106
+ "parse_color",
107
+ "publish_transcript",
108
+ "tokenize_sgr",
109
+ ]