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,212 @@
1
+ """Window-budget — model-driven summarization (port of TS
2
+ ``src/window-budget/summarize/condense.ts``).
3
+
4
+ :func:`summarize` is the single condensing primitive: given the dropped
5
+ prefix of a transcript and a :class:`SummarizeDeps` bundle, it flattens those
6
+ messages, asks the injectable model completer to write a structured digest,
7
+ and returns a synthetic :class:`~induscode.window_budget.contract.Summary`
8
+ message that stands in for the slice it covers.
9
+
10
+ Both condensing scopes flow through this one function:
11
+
12
+ - ``"session"`` — the active-session checkpoint (head condensed, tail kept by
13
+ the caller).
14
+ - ``"branch"`` — an abandoned branch archived into one message.
15
+
16
+ The only difference is the scope flag forwarded into
17
+ :func:`~.prompt.build_summary_prompt` (which selects the framing line).
18
+ :func:`condense_scope` is the thin branch-archival entrypoint that pins
19
+ ``scope="branch"``.
20
+
21
+ The completer is injectable (:attr:`SummarizeDeps.complete`, default the
22
+ framework's :func:`indusagi.ai.complete_simple` — verified Python signature
23
+ ``(model, context, options=None, logger=None) -> AssistantMessage``) so tests
24
+ run with no network — pass a stub that returns a canned
25
+ :class:`indusagi.ai.AssistantMessage`. When no ``model`` is supplied there is
26
+ nothing to call, so :func:`summarize` degrades to a deterministic local
27
+ digest rather than guessing a model — keeping it usable in a bare/offline
28
+ build.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from dataclasses import dataclass, replace
35
+ from typing import Any
36
+
37
+ from indusagi.ai import (
38
+ AssistantMessage,
39
+ Context,
40
+ SimpleStreamOptions,
41
+ UserMessage,
42
+ complete_simple,
43
+ )
44
+
45
+ from ..contract import (
46
+ AgentMessage,
47
+ CompleteFn,
48
+ CondenseScope,
49
+ Model,
50
+ Summary,
51
+ )
52
+ from .prompt import CONDENSER_BRIEF, build_summary_prompt, flatten_transcript
53
+
54
+ __all__ = [
55
+ "SummarizeDeps",
56
+ "condense_scope",
57
+ "flatten_transcript",
58
+ "summarize",
59
+ ]
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Dependencies
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, kw_only=True)
68
+ class SummarizeDeps:
69
+ """What :func:`summarize` needs. Every field is optional so the function
70
+ is network-free-testable: omit ``model`` for a local fallback digest, or
71
+ inject a ``complete`` stub for a scripted model response.
72
+ """
73
+
74
+ # Injectable completer (default: framework complete_simple).
75
+ complete: CompleteFn | None = None
76
+
77
+ # The summarization model. When omitted, a deterministic local digest is used.
78
+ model: Model | None = None
79
+
80
+ # Which kind of condense this is; defaults to "session".
81
+ scope: CondenseScope = "session"
82
+
83
+ # An earlier digest to refresh/extend (iterative-refresh / branch carry-in).
84
+ prior_digest: str | None = None
85
+
86
+ # Cancellation signal forwarded to the completer (the framework's
87
+ # CancelToken — kept opaque here; it lands on SimpleStreamOptions.signal).
88
+ signal: Any = None
89
+
90
+ # Optional cap on the digest size, in tokens, forwarded to the
91
+ # completer's `maxTokens`. A modest ceiling keeps the summary itself from
92
+ # eating the window. When omitted the model's own default applies.
93
+ max_tokens: int | None = None
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Synthetic summary message
98
+ # ---------------------------------------------------------------------------
99
+
100
+ # The header line stamped onto the synthetic summary so a human (and the next
101
+ # condense pass, via the `digest:` flattener case) can tell it apart from a
102
+ # real user turn. Re-authored wording (verbatim from the TS rebuild) — not
103
+ # the legacy `<summary>` framing, and DISTINCT from the framework
104
+ # `indusagi.runtime.memory` compactor's `[condensed earlier context]` heading.
105
+ _DIGEST_HEADER = "[session digest — older turns condensed]"
106
+ _BRANCH_HEADER = "[branch digest — archived from a path not taken]"
107
+
108
+
109
+ def _to_summary_message(digest: str, scope: CondenseScope) -> AgentMessage:
110
+ """Wrap digest text into a synthetic :data:`AgentMessage`. Modeled as a
111
+ plain ``user``-role message carrying the digest as text: it is the
112
+ simplest legal ``AgentMessage``, passes through the LLM conversion
113
+ natively, and the conductor splices it straight back into the message
114
+ list with no special handling."""
115
+ header = _BRANCH_HEADER if scope == "branch" else _DIGEST_HEADER
116
+ return UserMessage(
117
+ content=f"{header}\n\n{digest}",
118
+ timestamp=int(time.time() * 1000),
119
+ )
120
+
121
+
122
+ def _text_of(message: AssistantMessage) -> str:
123
+ """Extract the concatenated text from a completer's assistant message."""
124
+ parts: list[str] = []
125
+ for block in message.content:
126
+ if getattr(block, "type", None) == "text" and block.text.strip():
127
+ parts.append(block.text)
128
+ return "\n".join(parts).strip()
129
+
130
+
131
+ def _local_fallback_digest(
132
+ messages: list[AgentMessage], prior_digest: str | None = None
133
+ ) -> str:
134
+ """The offline fallback digest used when no ``model`` is supplied. It is
135
+ not a model summary — it simply records that the slice was elided and
136
+ preserves the prior digest if one was carried in, so a bare build never
137
+ silently loses the marker."""
138
+ lines = [
139
+ "# Carryover",
140
+ f"{len(messages)} earlier message(s) were elided without a summarization model bound.",
141
+ ]
142
+ if prior_digest and prior_digest.strip():
143
+ lines.extend(["", prior_digest.strip()])
144
+ return "\n".join(lines)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Core
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ async def summarize(
153
+ messages: list[AgentMessage],
154
+ deps: SummarizeDeps | None = None,
155
+ ) -> Summary:
156
+ """Summarize a slice of dropped messages into a single synthetic
157
+ :class:`~induscode.window_budget.contract.Summary`.
158
+
159
+ One code path for both scopes — ``deps.scope`` (default ``"session"``)
160
+ only changes the prompt framing. With a ``model`` bound it calls
161
+ ``deps.complete`` (default :func:`indusagi.ai.complete_simple`) once with
162
+ :data:`~.prompt.CONDENSER_BRIEF` as the system prompt and
163
+ :func:`~.prompt.build_summary_prompt` as the user turn; otherwise it
164
+ returns a deterministic local digest. Never raises on an empty model
165
+ reply — it falls back to the local digest so a ``Summary`` is always
166
+ produced.
167
+
168
+ :returns: a ``Summary`` whose ``message`` replaces the ``covered_count``
169
+ source messages it summarizes.
170
+ """
171
+ if deps is None:
172
+ deps = SummarizeDeps()
173
+ scope: CondenseScope = deps.scope
174
+ covered_count = len(messages)
175
+
176
+ # No model bound → deterministic, network-free local digest.
177
+ if deps.model is None:
178
+ digest = _local_fallback_digest(messages, deps.prior_digest)
179
+ return Summary(message=_to_summary_message(digest, scope), covered_count=covered_count)
180
+
181
+ prompt = build_summary_prompt(messages, scope, deps.prior_digest)
182
+ context = Context(
183
+ systemPrompt=CONDENSER_BRIEF,
184
+ messages=[UserMessage(content=prompt, timestamp=int(time.time() * 1000))],
185
+ )
186
+ options = SimpleStreamOptions(reasoning="high")
187
+ if deps.max_tokens is not None:
188
+ options.maxTokens = deps.max_tokens
189
+ if deps.signal is not None:
190
+ options.signal = deps.signal
191
+
192
+ complete: CompleteFn = deps.complete if deps.complete is not None else complete_simple
193
+ reply = await complete(deps.model, context, options)
194
+
195
+ digest = _text_of(reply)
196
+ final_digest = digest or _local_fallback_digest(messages, deps.prior_digest)
197
+ return Summary(
198
+ message=_to_summary_message(final_digest, scope),
199
+ covered_count=covered_count,
200
+ )
201
+
202
+
203
+ async def condense_scope(
204
+ messages: list[AgentMessage],
205
+ deps: SummarizeDeps | None = None,
206
+ ) -> Summary:
207
+ """Branch-archival entrypoint: condense an abandoned branch into one
208
+ summary message. A thin wrapper that pins ``scope="branch"`` and
209
+ delegates to :func:`summarize` — the branch case is the same machinery,
210
+ not a separate engine."""
211
+ base = deps if deps is not None else SummarizeDeps()
212
+ return await summarize(messages, replace(base, scope="branch"))
@@ -0,0 +1,241 @@
1
+ """Window-budget — summarization prompt assembly (port of TS
2
+ ``src/window-budget/summarize/prompt.ts``; re-authored prompt, copied
3
+ VERBATIM here).
4
+
5
+ When the transcript crosses budget, the older head is *condensed* into one
6
+ synthetic message. This module turns the dropped slice of
7
+ :data:`~induscode.window_budget.contract.AgentMessage` s into the single text
8
+ payload handed to the model completer. Two pieces ship here:
9
+
10
+ 1. :data:`CONDENSER_BRIEF` — the system-prompt brief that frames the model as
11
+ a *recorder*, not a continuation of the chat: read the transcript as data
12
+ and emit only the structured digest.
13
+ 2. :func:`build_summary_prompt` — flattens the dropped messages into a tagged
14
+ block and appends the section template the model fills in.
15
+
16
+ OWN VOCABULARY (an original prompt design):
17
+
18
+ - Section headings are ``# Objective / # Guardrails /
19
+ # Status (Shipped / Active / Stuck) / # Rationale / # Plan / # Carryover``.
20
+ - The transcript is wrapped in ``<scrollback>…</scrollback>`` and a prior
21
+ digest (the branch-archival / iterative-refresh path) in
22
+ ``<carried-digest>…</carried-digest>``.
23
+ - Per-message lines use ``» role:`` markers (``» you``, ``» agent``,
24
+ ``» agent.plan``, ``» agent.call``, ``» tool``).
25
+
26
+ No token math, no model calls, no I/O here — only string assembly. The scope
27
+ flag selects the framing line (active-session checkpoint vs abandoned-branch
28
+ archive); everything else is one code path.
29
+
30
+ Port note: the flattener's role dispatch covers the agent's custom session
31
+ messages (``bashExecution`` / ``custom`` / ``branchSummary`` /
32
+ ``compactionSummary``) EXPLICITLY — the Python ``indusagi.ai.AgentMessage``
33
+ alias excludes them (analysis 05 risk-2), and probing is ``getattr`` duck
34
+ typing over the frozen message dataclasses, not dict access.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+
42
+ from ..contract import AgentMessage, CondenseScope
43
+
44
+ __all__ = [
45
+ "CONDENSER_BRIEF",
46
+ "build_summary_prompt",
47
+ "flatten_transcript",
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # System brief
53
+ # ---------------------------------------------------------------------------
54
+
55
+ #: The system-prompt brief for the condenser model. Frames the request as a
56
+ #: record-keeping task — the model must NOT answer, continue, or act on the
57
+ #: conversation; it only transcribes it into the fixed section layout.
58
+ #: Re-authored wording (copied verbatim from the TS rebuild); shares no
59
+ #: sentences with the legacy ``SUMMARIZATION_SYSTEM_PROMPT``.
60
+ CONDENSER_BRIEF = "\n".join(
61
+ [
62
+ "You are a transcript recorder for a long-running coding session.",
63
+ "The text you receive is archival data, not a live chat — do NOT reply to it,",
64
+ "continue it, run tools, or ask questions. Your sole job is to distill it into",
65
+ "a compact, faithful digest under the exact headings requested below.",
66
+ "",
67
+ "Rules:",
68
+ "- Preserve concrete facts: file paths, identifiers, commands, error text,",
69
+ " decisions, and anything the session would need to resume without the",
70
+ " original turns.",
71
+ "- Prefer specifics over paraphrase; never invent details that are not present.",
72
+ "- Keep each heading even if a section is empty (write `none` under it).",
73
+ "- Emit only the digest — no preamble, no sign-off, no commentary.",
74
+ ]
75
+ )
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Transcript flattening
80
+ # ---------------------------------------------------------------------------
81
+
82
+ # Marker prefix for every flattened turn line.
83
+ _TURN_MARK = "»"
84
+
85
+ _TRAILING_WS_RE = re.compile(r"[ \t]+\n")
86
+
87
+
88
+ def _tidy(text: str) -> str:
89
+ """Collapse runs of whitespace so the flattened block stays compact."""
90
+ return _TRAILING_WS_RE.sub("\n", text.replace("\r\n", "\n")).strip()
91
+
92
+
93
+ def _read_content(content: object) -> str:
94
+ """Pull plain text out of a string-or-content-blocks field."""
95
+ if isinstance(content, str):
96
+ return content
97
+ parts: list[str] = []
98
+ if isinstance(content, (list, tuple)):
99
+ for block in content:
100
+ block_type = getattr(block, "type", None)
101
+ if block_type == "text":
102
+ text = getattr(block, "text", None)
103
+ if isinstance(text, str):
104
+ parts.append(text)
105
+ elif block_type == "image":
106
+ parts.append("[image]")
107
+ return "\n".join(parts)
108
+
109
+
110
+ def _safe_json(value: object) -> str:
111
+ """JSON-serialize that never raises and never explodes the line width."""
112
+ try:
113
+ return json.dumps(value, default=str, separators=(",", ":"))
114
+ except Exception:
115
+ return "{}"
116
+
117
+
118
+ def _flatten_message(message: AgentMessage) -> str:
119
+ """Render a single :data:`AgentMessage` as one-or-more ``» role:`` lines.
120
+ Returns ``""`` for messages with no projectable content (they are
121
+ skipped). This is the re-authored flattener — role markers and ordering
122
+ differ from the legacy ``serializeConversation``.
123
+ """
124
+ role = getattr(message, "role", None)
125
+
126
+ if role == "user":
127
+ text = _tidy(_read_content(getattr(message, "content", None)))
128
+ return f"{_TURN_MARK} you: {text}" if text else ""
129
+
130
+ if role == "assistant":
131
+ lines: list[str] = []
132
+ content = getattr(message, "content", None) or ()
133
+ for block in content:
134
+ block_type = getattr(block, "type", None)
135
+ if block_type == "text" and getattr(block, "text", "").strip():
136
+ lines.append(f"{_TURN_MARK} agent: {_tidy(block.text)}")
137
+ elif block_type == "thinking" and getattr(block, "thinking", "").strip():
138
+ lines.append(f"{_TURN_MARK} agent.plan: {_tidy(block.thinking)}")
139
+ elif block_type == "toolCall":
140
+ args = _safe_json(getattr(block, "arguments", None))
141
+ lines.append(f"{_TURN_MARK} agent.call {block.name}: {args}")
142
+ return "\n".join(lines)
143
+
144
+ if role == "toolResult":
145
+ text = _tidy(_read_content(getattr(message, "content", None)))
146
+ tag = "tool!err" if getattr(message, "isError", False) else "tool"
147
+ tool_name = getattr(message, "toolName", "")
148
+ return f"{_TURN_MARK} {tag} ({tool_name}): {text}"
149
+
150
+ if role == "bashExecution":
151
+ out = _tidy(getattr(message, "output", "") or "")
152
+ exit_code = getattr(message, "exitCode", None)
153
+ code = exit_code if exit_code is not None else "n/a"
154
+ command = _tidy(getattr(message, "command", "") or "")
155
+ return f"{_TURN_MARK} shell$ {command} [exit {code}]\n{out}"
156
+
157
+ if role == "custom":
158
+ text = _tidy(_read_content(getattr(message, "content", None)))
159
+ custom_type = getattr(message, "customType", "")
160
+ return f"{_TURN_MARK} note ({custom_type}): {text}" if text else ""
161
+
162
+ if role in ("branchSummary", "compactionSummary"):
163
+ text = _tidy(getattr(message, "summary", "") or "")
164
+ return f"{_TURN_MARK} digest: {text}" if text else ""
165
+
166
+ return ""
167
+
168
+
169
+ def flatten_transcript(messages: list[AgentMessage]) -> str:
170
+ """Flatten a list of messages into a single ``\\n``-joined block of
171
+ ``» role:`` lines. Exported so the condenser can reuse it for
172
+ diagnostics/tests."""
173
+ lines: list[str] = []
174
+ for message in messages:
175
+ rendered = _flatten_message(message)
176
+ if rendered:
177
+ lines.append(rendered)
178
+ return "\n".join(lines)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Prompt builder
183
+ # ---------------------------------------------------------------------------
184
+
185
+ # The re-authored section template the model fills in (verbatim from the TS
186
+ # rebuild — these headings are pinned by tests and the digest parser).
187
+ _SECTION_TEMPLATE = "\n".join(
188
+ [
189
+ "# Objective",
190
+ "# Guardrails",
191
+ "# Status (Shipped / Active / Stuck)",
192
+ "# Rationale",
193
+ "# Plan",
194
+ "# Carryover",
195
+ ]
196
+ )
197
+
198
+
199
+ def _framing(scope: CondenseScope) -> str:
200
+ """The scope-specific framing line that precedes the template."""
201
+ if scope == "branch":
202
+ return (
203
+ "Archive this abandoned branch so it can be folded back as context later. "
204
+ "Distill it under these headings:"
205
+ )
206
+ return (
207
+ "Condense the older portion of this active session so recent turns stay verbatim. "
208
+ "Distill it under these headings:"
209
+ )
210
+
211
+
212
+ def build_summary_prompt(
213
+ messages: list[AgentMessage],
214
+ scope: CondenseScope,
215
+ prior_digest: str | None = None,
216
+ ) -> str:
217
+ """Build the full summarization prompt for a slice of dropped messages.
218
+
219
+ The result is one string: the scope framing, the flattened transcript
220
+ wrapped in ``<scrollback>``, an optional carried digest wrapped in
221
+ ``<carried-digest>`` (the iterative-refresh / branch-archival input), and
222
+ the section template the model fills in. Pass this as the user turn to
223
+ the injectable completer; pair it with :data:`CONDENSER_BRIEF` as the
224
+ system prompt.
225
+
226
+ :param messages: the dropped prefix to summarize
227
+ :param scope: ``"session"`` (active checkpoint) or ``"branch"`` (archive)
228
+ :param prior_digest: an optional earlier digest to refresh/extend,
229
+ threaded in under ``<carried-digest>`` so the model merges rather
230
+ than re-derives. Omit for a first-pass condense.
231
+ """
232
+ transcript = flatten_transcript(messages)
233
+ blocks: list[str] = [_framing(scope)]
234
+
235
+ if prior_digest and prior_digest.strip():
236
+ blocks.append(f"<carried-digest>\n{_tidy(prior_digest)}\n</carried-digest>")
237
+
238
+ blocks.append(f"<scrollback>\n{transcript}\n</scrollback>")
239
+ blocks.append(_SECTION_TEMPLATE)
240
+
241
+ return "\n\n".join(blocks)
@@ -0,0 +1,30 @@
1
+ """Workspace — product identity (:data:`BRAND`, :data:`VERSION`) and the
2
+ resolved on-disk layout (:class:`Workspace` via :func:`create_workspace`).
3
+
4
+ Port of the TS ``src/workspace`` barrel (``runtime-detect.ts`` dropped; see
5
+ the :mod:`~induscode.workspace.locator` module docstring for why).
6
+ """
7
+
8
+ from .brand import BRAND, Brand, VERSION
9
+ from .locator import (
10
+ DIRECTORY_KEYS,
11
+ ENV_FRAMEWORK_HOME,
12
+ LAYOUT,
13
+ Workspace,
14
+ WorkspaceOverrides,
15
+ create_workspace,
16
+ ensure_dirs,
17
+ )
18
+
19
+ __all__ = [
20
+ "BRAND",
21
+ "Brand",
22
+ "DIRECTORY_KEYS",
23
+ "ENV_FRAMEWORK_HOME",
24
+ "LAYOUT",
25
+ "VERSION",
26
+ "Workspace",
27
+ "WorkspaceOverrides",
28
+ "create_workspace",
29
+ "ensure_dirs",
30
+ ]
@@ -0,0 +1,96 @@
1
+ """Brand record — the single source of truth for every identity literal.
2
+
3
+ One frozen :class:`Brand` value. Nothing else in the app hard-codes the
4
+ product name, the bin names, the env-var namespace, or the share-viewer
5
+ origin. A rebrand edits this record and nothing else.
6
+
7
+ Port note (TS ``src/workspace/brand.ts``)
8
+ -----------------------------------------
9
+ The TS record carried ``profileDirName: ".indusagi"`` plus a nested
10
+ ``stateDirName: "agent"`` (profile root ``~/.indusagi/agent``). The Python
11
+ build owns the flat ``~/.pindusagi`` root shared with the ``indusagi``
12
+ framework — there is no ``agent/`` nesting because nothing else shares the
13
+ directory — so ``state_dir_name`` is dropped and :attr:`Brand.profile_dir_name`
14
+ is composed from the framework's own brand record
15
+ (:data:`indusagi.shell_app.locate.BRAND`) so the two can never drift.
16
+
17
+ The bin names are the locked Python renames (``pindus``/``induscode``; npm
18
+ owns ``indus``/``indusagi``). The env-var names and the share-viewer fields
19
+ are kept verbatim from TS.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from importlib import metadata as _metadata
26
+ from typing import Final
27
+
28
+ from indusagi.shell_app.locate import BRAND as _FRAMEWORK_BRAND
29
+
30
+ __all__ = ["BRAND", "Brand", "VERSION"]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Brand:
35
+ """The shape of the brand record.
36
+
37
+ Field names are the mechanical snake_case renames of the TS ``Brand``
38
+ contract (``boot/contract.ts``); values are the induscode identity.
39
+ """
40
+
41
+ # The product's machine name — printed by `--version`, stamped on traces.
42
+ name: str
43
+ # The product's display label, used in banners and prose.
44
+ label: str
45
+ # The dot-directory carved under the user's home for all per-user state
46
+ # (e.g. ".pindusagi"). Composed from the framework brand; the Python build
47
+ # shares the framework's flat root (no TS-style "agent/" nesting).
48
+ profile_dir_name: str
49
+ # The console-script names users invoke (primary first).
50
+ bin_names: tuple[str, ...]
51
+ # The namespace prefix on every environment variable the app reads.
52
+ env_prefix: str
53
+ # Env var that relocates the entire agent profile directory.
54
+ env_profile_dir: str
55
+ # Env var that enables verbose diagnostics.
56
+ env_debug: str
57
+ # Env var that overrides the share-viewer origin.
58
+ env_share_viewer: str
59
+ # Default origin shared transcripts are viewed at.
60
+ share_viewer_url: str
61
+
62
+
63
+ #: The concrete induscode identity. Frozen so the record cannot be mutated by
64
+ #: any consumer; a drift from the contract is a construction error here rather
65
+ #: than a runtime surprise downstream.
66
+ BRAND: Final[Brand] = Brand(
67
+ name="induscode",
68
+ label="induscode",
69
+ profile_dir_name=_FRAMEWORK_BRAND.profile_dir_name, # ".pindusagi"
70
+ bin_names=("pindus", "induscode"),
71
+ env_prefix="INDUSAGI",
72
+ env_profile_dir="INDUSAGI_CODING_AGENT_DIR",
73
+ env_debug="INDUSAGI_DEBUG",
74
+ env_share_viewer="INDUSAGI_SHARE_VIEWER_URL",
75
+ share_viewer_url="https://buildwithindusagi.ai/session/",
76
+ )
77
+
78
+
79
+ def _detect_version() -> str:
80
+ """Resolve the product version from the installed package metadata.
81
+
82
+ Single-sourced from ``pyproject.toml`` via :mod:`importlib.metadata` —
83
+ the TS build duplicated the version as a literal (``VERSION = "0.1.62"``
84
+ in ``brand.ts`` next to ``package.json``); the port fixes that. The
85
+ fallback only fires when the package is imported without being installed
86
+ (e.g. straight off a source checkout's ``sys.path``).
87
+ """
88
+ try:
89
+ return _metadata.version("induscode")
90
+ except _metadata.PackageNotFoundError: # pragma: no cover - dev checkout only
91
+ return "0.1.0.dev0"
92
+
93
+
94
+ #: The product version — printed by `--version` and shown on the startup
95
+ #: banner. (TS lineage: indusagi-coding-agent v0.1.62.)
96
+ VERSION: Final[str] = _detect_version()