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,170 @@
1
+ """Window-budget — the condenser factory (the conductor-consumable seam;
2
+ port of TS ``src/window-budget/condenser.ts``).
3
+
4
+ :func:`create_condenser` ties the three pure pieces together into a single
5
+ function the conductor can drop into its auto-compaction hook:
6
+
7
+ 1. **Gate** — :func:`~.budget.is_over_budget` asks "is this transcript
8
+ large enough to condense?" against the bound model's window and the
9
+ :class:`~.contract.BudgetPolicy`.
10
+ 2. **Slice** — :func:`~.budget.plan_slice` splits the transcript into a
11
+ ``dropped`` head (older turns to fold away) and a verbatim ``kept`` tail,
12
+ never cutting between a ``toolCall`` and its ``toolResult``.
13
+ 3. **Condense** — :func:`~.summarize.summarize` turns the dropped head into
14
+ one synthetic digest message (scope ``"session"``), via the injectable
15
+ model completer.
16
+
17
+ The returned value is a :data:`~.contract.Condenser` — structurally the
18
+ conductor ``CondenseFn``
19
+ (``(messages) -> list[AgentMessage] | Awaitable[list[AgentMessage]]``):
20
+
21
+ - When over budget it returns ``[summary_message, *kept]`` (a strictly
22
+ smaller transcript: one digest in place of the dropped prefix, plus the
23
+ recent tail).
24
+ - Otherwise it returns the input list unchanged — the SAME list object (the
25
+ no-op identity the conductor expects when nothing needs doing; tests pin
26
+ ``is``-identity on this path).
27
+
28
+ It is **network-free-testable**: with no ``model`` bound the gate cannot
29
+ measure a window, so the condenser is an identity transform; inject a
30
+ :class:`~.contract.CompleteFn` stub (and a ``model``) to drive the summary
31
+ path with no real API call.
32
+
33
+ Branch-archival is *not* a second engine — :func:`condense_scope` forwards to
34
+ the shared :func:`~.summarize.summarize` core pinned to scope ``"branch"``.
35
+ :func:`condense` is the matching session-scope entrypoint. Both are
36
+ re-exported here so callers can summarize a slice directly without building a
37
+ full ``Condenser``.
38
+
39
+ TWO COMPACTION ENGINES — KEEP THEM DISTINCT (analysis 05 §1 / risk-3)
40
+ ---------------------------------------------------------------------
41
+ This module is the app's **conductor ``CondenseFn``** seam. The framework
42
+ ships its own compactor, :mod:`indusagi.runtime.memory`
43
+ (``should_compact`` / ``find_cut_point`` / ``summarize`` / ``compact``), but
44
+ it is a DIFFERENT engine and is never wired into the conductor:
45
+
46
+ ============ ============================================= ==========================================
47
+ window_budget (this module) indusagi.runtime.memory (framework)
48
+ ============ ============================================= ==========================================
49
+ message type ``indusagi.ai`` ``AgentMessage`` (+ agent ``llmgateway.contract.Turn``
50
+ custom messages)
51
+ keep_recent **tokens** (default 6000) **turns** (default 8)
52
+ trigger ratio **0.75** of the window ratio **0.8**
53
+ completer ``indusagi.ai.complete_simple`` (injectable) ``ModelInvoker``
54
+ digest head ``[session digest …]`` / ``[branch digest …]`` ``[condensed earlier context]``
55
+ ============ ============================================= ==========================================
56
+
57
+ Do not substitute one for the other: the policies, prompts, and digest
58
+ headers differ, and tests pin this module's headers.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ from dataclasses import replace
64
+
65
+ from .budget import is_over_budget, plan_slice
66
+ from .contract import (
67
+ AgentMessage,
68
+ BudgetPolicy,
69
+ CompleteFn,
70
+ Condenser,
71
+ CondenserDeps,
72
+ Model,
73
+ Summary,
74
+ )
75
+ from .summarize import SummarizeDeps, condense_scope, summarize
76
+
77
+ __all__ = [
78
+ "DEFAULT_POLICY",
79
+ "condense",
80
+ "condense_scope",
81
+ "create_condenser",
82
+ ]
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Default policy (config-sourced thresholds)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ #: The fallback :class:`~.contract.BudgetPolicy` when the caller supplies
90
+ #: none. These are ordinary configuration defaults — a window-relative ratio
91
+ #: plus a token tail — computed from the model's own context window rather
92
+ #: than fixed magic numbers.
93
+ #:
94
+ #: - ``trigger_ratio`` 0.75 — condense once ~three-quarters of the window is used.
95
+ #: - ``keep_recent`` 6000 — keep roughly the last 6k tokens of turns verbatim.
96
+ #: - ``reserve_tokens`` 2048 — carve a little headroom off the window first.
97
+ DEFAULT_POLICY = BudgetPolicy(
98
+ trigger_ratio=0.75,
99
+ keep_recent=6000,
100
+ reserve_tokens=2048,
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Factory
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ def create_condenser(deps: CondenserDeps | None = None) -> Condenser:
110
+ """Build a :data:`~.contract.Condenser` from a
111
+ :class:`~.contract.CondenserDeps` bundle. The result plugs straight into
112
+ the conductor's ``CondenseFn`` seam.
113
+
114
+ Behavior of the returned condenser, given a transcript ``messages``:
115
+
116
+ - If no ``model`` is bound (so the window is unknown), or the transcript
117
+ is **not** over budget, return ``messages`` **unchanged** (the same
118
+ list object).
119
+ - Otherwise ``plan_slice`` the transcript; if there is nothing to drop
120
+ (``cut == 0``) return it unchanged; else ``summarize`` the dropped head
121
+ (scope ``"session"``) and return ``[summary.message, *plan.kept]``.
122
+
123
+ All dependencies are optional, so ``create_condenser()`` yields a
124
+ working, network-free condenser (a no-op until a model and policy are
125
+ supplied).
126
+ """
127
+ if deps is None:
128
+ deps = CondenserDeps()
129
+ policy: BudgetPolicy = deps.policy if deps.policy is not None else DEFAULT_POLICY
130
+ model: Model | None = deps.model
131
+ complete: CompleteFn | None = deps.complete
132
+
133
+ async def condenser(messages: list[AgentMessage]) -> list[AgentMessage]:
134
+ # No model → no window to measure against → nothing to do (identity).
135
+ if model is None:
136
+ return messages
137
+
138
+ # Under budget → leave the transcript exactly as-is (conductor no-op).
139
+ if not is_over_budget(messages, model, policy):
140
+ return messages
141
+
142
+ # Decide where to cut (forward prefix-sum + binary search, tool-pair safe).
143
+ plan = plan_slice(messages, policy)
144
+ if plan.cut == 0 or not plan.dropped:
145
+ # Nothing condensable (everything is within the recent tail) → identity.
146
+ return messages
147
+
148
+ # Fold the dropped head into one synthetic digest, then splice it
149
+ # ahead of the verbatim recent tail.
150
+ summary = await condense(plan.dropped, SummarizeDeps(complete=complete, model=model))
151
+ return [summary.message, *plan.kept]
152
+
153
+ return condenser
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Direct summarization entrypoints (session + branch)
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ async def condense(
162
+ messages: list[AgentMessage],
163
+ deps: SummarizeDeps | None = None,
164
+ ) -> Summary:
165
+ """Session-scope summarization: fold a slice of dropped messages into one
166
+ digest for the active session. A thin wrapper over the shared
167
+ :func:`~.summarize.summarize` core pinned to scope ``"session"`` (the
168
+ default), so callers don't pass the flag."""
169
+ base = deps if deps is not None else SummarizeDeps()
170
+ return await summarize(messages, replace(base, scope="session"))
@@ -0,0 +1,329 @@
1
+ """Window-budget contract — the FROZEN type surface of the context-window
2
+ budgeting subsystem (port of TS ``src/window-budget/contract.ts``).
3
+
4
+ A long-running coding session accrues messages — user turns, assistant turns,
5
+ tool calls and their results — until the transcript approaches the model's
6
+ context window. This subsystem keeps the session usable by *condensing* older
7
+ history into a single structured summary message while keeping a verbatim
8
+ tail of the most-recent turns. It answers three questions:
9
+
10
+ 1. **Budget** — *when* are we over budget? A :class:`BudgetPolicy` carries
11
+ config-sourced thresholds (no magic literals);
12
+ :func:`~induscode.window_budget.budget.gate.is_over_budget` decides from a
13
+ real :class:`TokenEstimate`.
14
+ 2. **Slice** — *where* do we cut?
15
+ :func:`~induscode.window_budget.budget.slice.plan_slice` computes a
16
+ :class:`CondensePlan` via a forward prefix-sum + binary search over a
17
+ cumulative-token array, never splitting a tool_call from its tool_result.
18
+ 3. **Condense** — *how* do we compress the dropped portion? A
19
+ :data:`Condenser` flattens the dropped messages, asks an injectable model
20
+ completer for a structured :class:`Summary`, and returns the rebuilt
21
+ transcript (summary + kept tail).
22
+
23
+ This module declares *only* shapes plus the function-type seams — no behavior,
24
+ no I/O, no prompt strings. Every later window-budget module (the meter, the
25
+ slice planner, the condenser, the branch archivist, the prompt templates, and
26
+ the public barrel) is written against the names declared here.
27
+
28
+ Design stance:
29
+
30
+ - Thresholds are **configuration, not constants**. :class:`BudgetPolicy`
31
+ fields are supplied by the caller; this module bakes in no fixed token
32
+ fingerprints. Defaults, if any, live in the implementation and are computed
33
+ relative to the model's own context window.
34
+ - Branch-summarization is **not a separate engine**. It collapses into the
35
+ condenser behind a :data:`CondenseScope` flag (``"session" | "branch"``),
36
+ so there is one prompt-assembly path, one token estimator, one slicer.
37
+ - The model completer is **injectable** (:class:`CompleteFn`, default the
38
+ framework's :func:`indusagi.ai.complete_simple`) so the condenser runs in
39
+ tests with no network.
40
+ - The produced condenser is **conductor-consumable**: a :data:`Condenser` is
41
+ structurally the conductor ``CondenseFn``
42
+ (``(messages) -> list[AgentMessage] | Awaitable[list[AgentMessage]]``), so a
43
+ :class:`CondenserDeps`-built condenser plugs straight into the conductor's
44
+ auto-compaction seam.
45
+
46
+ Port notes (Python framework deltas vs the TS source)
47
+ -----------------------------------------------------
48
+ - **``AgentMessage`` relocated AND narrower.** In TS it came from
49
+ ``indusagi/agent`` and the declaration-merged union already covered the
50
+ agent's custom session messages. In the Python framework the alias lives at
51
+ :data:`indusagi.ai.AgentMessage` and equals the bare LLM ``Message`` union
52
+ (``UserMessage | AssistantMessage | ToolResultMessage``) only. The agent's
53
+ custom message kinds — :class:`indusagi.agent.BashExecutionMessage`,
54
+ :class:`indusagi.agent.CustomMessage`,
55
+ :class:`indusagi.agent.BranchSummaryMessage`,
56
+ :class:`indusagi.agent.CompactionSummaryMessage` — are separate dataclasses
57
+ in :mod:`indusagi.agent`. This contract therefore **explicitly unions them**
58
+ into the app-level :data:`AgentMessage` alias, and every role-probing
59
+ consumer (the estimator, the slicer, the flattener) handles those roles via
60
+ ``getattr``/``isinstance`` duck probing, never dict access — messages are
61
+ frozen dataclasses here, not JSON records.
62
+ - ``Model`` drops the TS ``<TApi>`` generic — :class:`indusagi.ai.Model`
63
+ carries ``api`` as a plain string tag.
64
+ - The Web ``AbortSignal`` becomes the framework's ``CancelToken`` (forwarded
65
+ opaquely into ``SimpleStreamOptions.signal``; not re-typed here because the
66
+ framework keeps it internal).
67
+
68
+ Framework anchors (all from the ``indusagi`` package — the sibling rebuilt
69
+ framework this app targets):
70
+
71
+ - ``AgentMessage`` (base union), ``Model``, ``Usage``, ``complete_simple``
72
+ (and its ``Api`` / ``AssistantMessage`` / ``Context`` /
73
+ ``SimpleStreamOptions`` / ``StreamLogger`` shapes) ← ``indusagi.ai``
74
+ - ``BashExecutionMessage`` / ``CustomMessage`` / ``BranchSummaryMessage`` /
75
+ ``CompactionSummaryMessage`` ← ``indusagi.agent``
76
+
77
+ This module never re-declares these; it composes them.
78
+ """
79
+
80
+ from __future__ import annotations
81
+
82
+ from collections.abc import Awaitable, Callable
83
+ from dataclasses import dataclass
84
+ from typing import Literal, Protocol, TypeAlias
85
+
86
+ from indusagi.agent import (
87
+ BashExecutionMessage,
88
+ BranchSummaryMessage,
89
+ CompactionSummaryMessage,
90
+ CustomMessage,
91
+ )
92
+ from indusagi.ai import (
93
+ AgentMessage as AiAgentMessage,
94
+ )
95
+ from indusagi.ai import (
96
+ Api,
97
+ AssistantMessage,
98
+ Context,
99
+ Model,
100
+ SimpleStreamOptions,
101
+ StreamLogger,
102
+ Usage,
103
+ )
104
+
105
+ __all__ = [
106
+ "AgentMessage",
107
+ "Api",
108
+ "BudgetPolicy",
109
+ "CompleteFn",
110
+ "CondensePlan",
111
+ "CondenseScope",
112
+ "Condenser",
113
+ "CondenserDeps",
114
+ "Model",
115
+ "Summary",
116
+ "TokenEstimate",
117
+ "Usage",
118
+ ]
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Message vocabulary (re-exported framework vocabulary, widened)
123
+ # ---------------------------------------------------------------------------
124
+
125
+ #: The transcript message union this subsystem operates on. The framework's
126
+ #: :data:`indusagi.ai.AgentMessage` covers only the LLM ``Message`` trio, so —
127
+ #: unlike the TS source, where declaration merging widened it implicitly —
128
+ #: the agent's four custom session-message dataclasses are unioned in
129
+ #: EXPLICITLY here (analysis 05 risk-2). A silent omission would mis-estimate
130
+ #: tokens and skip those turns in digests.
131
+ AgentMessage: TypeAlias = (
132
+ AiAgentMessage
133
+ | BashExecutionMessage
134
+ | CustomMessage
135
+ | BranchSummaryMessage
136
+ | CompactionSummaryMessage
137
+ )
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Policy
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ @dataclass(frozen=True, slots=True, kw_only=True)
146
+ class BudgetPolicy:
147
+ """The config-sourced thresholds that govern when and how the transcript
148
+ is condensed. Every field is a *number the caller supplies* — there are no
149
+ magic token literals baked into this contract. An implementation may
150
+ provide defaults, but they are configuration values, not protected
151
+ constants, and deliberately differ from the legacy fingerprints.
152
+ """
153
+
154
+ # Fraction of the model's `contextWindow` at which condensing is
155
+ # triggered, in (0, 1]. The meter is "over budget" once the estimated
156
+ # context tokens cross `contextWindow * trigger_ratio`. Expressing the
157
+ # trigger as a ratio (rather than an absolute reserve) keeps it
158
+ # window-relative and free of baked-in token counts.
159
+ trigger_ratio: float
160
+
161
+ # How much of the recent transcript to keep verbatim, measured in
162
+ # estimated tokens. The slice planner preserves a tail of at least this
163
+ # many tokens (snapped to a legal boundary) and condenses everything
164
+ # before it.
165
+ keep_recent: int
166
+
167
+ # Optional token headroom to subtract from the window before the trigger
168
+ # is evaluated — a safety margin so a condense fires *before* the very
169
+ # next turn would overflow. When omitted, only `trigger_ratio` governs
170
+ # the trigger.
171
+ reserve_tokens: int | None = None
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Token measurement
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ @dataclass(frozen=True, slots=True, kw_only=True)
180
+ class TokenEstimate:
181
+ """The outcome of measuring a transcript against a model's window.
182
+
183
+ Anchors on the provider's authoritative ``usage`` count when available
184
+ (``anchored=True``) and falls back to a rough char/role estimate
185
+ otherwise; either way ``context_tokens`` is the figure ``is_over_budget``
186
+ compares to ``context_window``.
187
+ """
188
+
189
+ # Best estimate of tokens currently occupying the context window.
190
+ context_tokens: int
191
+
192
+ # The model's total context window, in tokens (`Model.contextWindow`).
193
+ context_window: int
194
+
195
+ # True when `context_tokens` is anchored on a real provider `usage`
196
+ # figure (only trailing, post-usage messages are estimated); False when
197
+ # the whole figure is a heuristic estimate.
198
+ anchored: bool
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Slice plan
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ @dataclass(frozen=True, slots=True, kw_only=True)
207
+ class CondensePlan:
208
+ """The pre-computed result of ``plan_slice``: the chosen cut index and the
209
+ resulting partition of the transcript.
210
+
211
+ The cut is found by a *forward* prefix-sum + binary search: build a
212
+ cumulative estimated-token array over the messages, binary-search for the
213
+ boundary index nearest ``total - keep_recent``, then nudge to the closest
214
+ *legal* boundary — never landing between a ``toolCall`` and its
215
+ ``toolResult``.
216
+
217
+ Invariants:
218
+
219
+ - ``len(kept) + len(dropped) == len(messages)``
220
+ - ``dropped`` is the prefix ``messages[0:cut]`` (the older slice to condense)
221
+ - ``kept`` is the suffix ``messages[cut:]`` (the verbatim recent tail)
222
+ - ``cut`` is ``0`` when nothing is condensable (everything kept).
223
+ """
224
+
225
+ # Index of the legal cut boundary: dropped = messages[:cut].
226
+ cut: int
227
+
228
+ # The verbatim recent tail preserved as-is (messages[cut:]).
229
+ kept: list[AgentMessage]
230
+
231
+ # The older slice to be folded into a `Summary` (messages[:cut]).
232
+ dropped: list[AgentMessage]
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Condense scope
237
+ # ---------------------------------------------------------------------------
238
+
239
+ #: Which kind of condensing run this is — the flag that collapses
240
+ #: branch-summarization into the single condenser path:
241
+ #:
242
+ #: - ``"session"`` — condense the *active* transcript in place (the trigger /
243
+ #: overflow path); keeps a recent tail, summarizes the head.
244
+ #: - ``"branch"`` — archive an *abandoned* branch into one summary message so
245
+ #: its context isn't lost on tree navigation; no verbatim tail is retained.
246
+ CondenseScope: TypeAlias = Literal["session", "branch"]
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Summary
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ @dataclass(frozen=True, slots=True, kw_only=True)
255
+ class Summary:
256
+ """The structured result of summarizing a slice of dropped messages: a
257
+ single synthetic :data:`AgentMessage` that stands in for the messages it
258
+ covers.
259
+ """
260
+
261
+ # The synthetic summary message to splice into the rebuilt transcript.
262
+ message: AgentMessage
263
+
264
+ # How many source messages this summary replaces.
265
+ covered_count: int
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Model completer seam (injectable, default = framework complete_simple)
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ class CompleteFn(Protocol):
274
+ """The injectable model-completion function. Structurally identical to
275
+ the framework's :func:`indusagi.ai.complete_simple`
276
+ (``(model, context, options=None, logger=None) -> AssistantMessage``), so
277
+ the real ``complete_simple`` is the drop-in default and a test can pass a
278
+ no-network stub of the same shape. The condenser uses this single call to
279
+ turn flattened transcript text into a :class:`Summary`.
280
+ """
281
+
282
+ def __call__(
283
+ self,
284
+ model: Model,
285
+ context: Context,
286
+ options: SimpleStreamOptions | None = None,
287
+ logger: StreamLogger | None = None,
288
+ ) -> Awaitable[AssistantMessage]: ...
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Condenser factory seam
293
+ # ---------------------------------------------------------------------------
294
+
295
+ #: A condenser: takes the active branch's messages and returns the rebuilt
296
+ #: transcript (condensed head + verbatim tail, or the input unchanged when
297
+ #: there is nothing to do).
298
+ #:
299
+ #: This type is **structurally compatible with the conductor's
300
+ #: ``CondenseFn``** (``(messages) -> list[AgentMessage] |
301
+ #: Awaitable[list[AgentMessage]]``), so the result of the condenser factory
302
+ #: plugs directly into the conductor's auto-compaction seam with no adapter.
303
+ Condenser: TypeAlias = Callable[
304
+ [list[AgentMessage]],
305
+ "list[AgentMessage] | Awaitable[list[AgentMessage]]",
306
+ ]
307
+
308
+
309
+ @dataclass(frozen=True, slots=True, kw_only=True)
310
+ class CondenserDeps:
311
+ """Dependencies for building a :data:`Condenser`. All fields are optional
312
+ so a bare ``create_condenser()`` yields a working, network-free-testable
313
+ condenser:
314
+
315
+ - ``complete`` defaults to the framework's ``complete_simple``.
316
+ - ``policy`` defaults to the implementation's config-sourced thresholds.
317
+ - ``model`` is the summarization model; when omitted the condenser falls
318
+ back to a no-op (return input unchanged) rather than guess a model.
319
+ """
320
+
321
+ # The injectable model completer (default: framework complete_simple).
322
+ # Inject a stub of `CompleteFn` shape to run the condenser w/o network.
323
+ complete: CompleteFn | None = None
324
+
325
+ # The model used to write the summary. When omitted, condensing is a no-op.
326
+ model: Model | None = None
327
+
328
+ # The config-sourced budget thresholds (default: implementation defaults).
329
+ policy: BudgetPolicy | None = None
@@ -0,0 +1,33 @@
1
+ """Window-budget — summarization subsystem barrel (port of TS
2
+ ``src/window-budget/summarize/index.ts``).
3
+
4
+ Model-driven condensing of the dropped transcript prefix into one synthetic
5
+ summary message, with re-authored prompts (new section names + a new tag
6
+ vocabulary). Branch-archival folds in behind the scope flag —
7
+ :func:`condense_scope` is the branch entrypoint over the same
8
+ :func:`summarize` core.
9
+ """
10
+
11
+ from ..contract import (
12
+ AgentMessage,
13
+ CompleteFn,
14
+ CondenseScope,
15
+ Model,
16
+ Summary,
17
+ )
18
+ from .condense import SummarizeDeps, condense_scope, summarize
19
+ from .prompt import CONDENSER_BRIEF, build_summary_prompt, flatten_transcript
20
+
21
+ __all__ = [
22
+ "AgentMessage",
23
+ "CONDENSER_BRIEF",
24
+ "CompleteFn",
25
+ "CondenseScope",
26
+ "Model",
27
+ "SummarizeDeps",
28
+ "Summary",
29
+ "build_summary_prompt",
30
+ "condense_scope",
31
+ "flatten_transcript",
32
+ "summarize",
33
+ ]