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,76 @@
1
+ """Window-budget — public barrel (port of TS ``src/window-budget/index.ts``).
2
+
3
+ The context-window budgeting subsystem: token measurement, slice planning,
4
+ and transcript condensing (with branch-archival collapsed in behind a scope
5
+ flag). Re-exports the frozen contract types plus the live functions: the
6
+ budget math (``estimate_tokens`` / ``is_over_budget`` / ``plan_slice``), the
7
+ summarization core (``summarize`` / ``condense_scope``), and the
8
+ conductor-consumable condenser factory (``create_condenser`` / ``condense``).
9
+
10
+ This is the app's OWN compaction engine over :mod:`indusagi.ai` messages and
11
+ ``complete_simple`` — it feeds the conductor's ``CondenseFn`` seam and is
12
+ deliberately NOT merged with the framework's :mod:`indusagi.runtime.memory`
13
+ compactor (different message types, policy semantics, prompts, and digest
14
+ headers; see :mod:`induscode.window_budget.condenser`).
15
+ """
16
+
17
+ from .budget import (
18
+ budget_limit,
19
+ estimate_message_tokens,
20
+ estimate_tokens,
21
+ is_over_budget,
22
+ plan_slice,
23
+ prefix_tokens,
24
+ )
25
+ from .condenser import DEFAULT_POLICY, condense, condense_scope, create_condenser
26
+ from .contract import (
27
+ AgentMessage,
28
+ Api,
29
+ BudgetPolicy,
30
+ CompleteFn,
31
+ CondensePlan,
32
+ Condenser,
33
+ CondenserDeps,
34
+ CondenseScope,
35
+ Model,
36
+ Summary,
37
+ TokenEstimate,
38
+ Usage,
39
+ )
40
+ from .summarize import (
41
+ CONDENSER_BRIEF,
42
+ SummarizeDeps,
43
+ build_summary_prompt,
44
+ flatten_transcript,
45
+ summarize,
46
+ )
47
+
48
+ __all__ = [
49
+ "AgentMessage",
50
+ "Api",
51
+ "BudgetPolicy",
52
+ "CONDENSER_BRIEF",
53
+ "CompleteFn",
54
+ "CondensePlan",
55
+ "CondenseScope",
56
+ "Condenser",
57
+ "CondenserDeps",
58
+ "DEFAULT_POLICY",
59
+ "Model",
60
+ "SummarizeDeps",
61
+ "Summary",
62
+ "TokenEstimate",
63
+ "Usage",
64
+ "budget_limit",
65
+ "build_summary_prompt",
66
+ "condense",
67
+ "condense_scope",
68
+ "create_condenser",
69
+ "estimate_message_tokens",
70
+ "estimate_tokens",
71
+ "flatten_transcript",
72
+ "is_over_budget",
73
+ "plan_slice",
74
+ "prefix_tokens",
75
+ "summarize",
76
+ ]
@@ -0,0 +1,26 @@
1
+ """Window-budget / budget — pure budget math (port of TS
2
+ ``src/window-budget/budget/index.ts``).
3
+
4
+ The arithmetic core of the window-budget subsystem, free of I/O, prompts, and
5
+ model calls:
6
+
7
+ - :func:`estimate_tokens` / :func:`estimate_message_tokens` /
8
+ :func:`prefix_tokens` — heuristic token measurement and the forward
9
+ cumulative-token array.
10
+ - :func:`is_over_budget` / :func:`budget_limit` — the window-relative trigger.
11
+ - :func:`plan_slice` — forward prefix-sum + binary-search cut planning that
12
+ never splits a tool_call from its tool_result.
13
+ """
14
+
15
+ from .estimate import estimate_message_tokens, estimate_tokens, prefix_tokens
16
+ from .gate import budget_limit, is_over_budget
17
+ from .slice import plan_slice
18
+
19
+ __all__ = [
20
+ "budget_limit",
21
+ "estimate_message_tokens",
22
+ "estimate_tokens",
23
+ "is_over_budget",
24
+ "plan_slice",
25
+ "prefix_tokens",
26
+ ]
@@ -0,0 +1,273 @@
1
+ """Token estimation — the heuristic "meter stick" for the window-budget
2
+ subsystem (port of TS ``src/window-budget/budget/estimate.ts``).
3
+
4
+ Before we can decide whether the transcript is over budget (``gate.py``) or
5
+ where to slice it (``slice.py``), we need a cheap, deterministic estimate of
6
+ how many tokens each :data:`AgentMessage` occupies. We do *not* call a
7
+ tokenizer; we walk the serialized content and apply a small, transparent
8
+ weighting model.
9
+
10
+ Design notes (clean-room, deliberately distinct from any chars/4 + flat-image
11
+ baseline):
12
+
13
+ - The estimate is computed over a *serialization* of each message's payload,
14
+ not over a JSON blob of the whole object — structural framing (braces,
15
+ quotes, field names) is accounted for with small per-block adds rather than
16
+ being measured character-for-character.
17
+ - Constants below are this module's OWN tunables (a fractional
18
+ bytes-per-token divisor plus per-block framing adds and a flat image cost).
19
+ They are intentionally not the legacy fingerprint values — and they are
20
+ NOT the framework's: ``indusagi``'s own estimator uses 4.0 chars/token;
21
+ this app-level meter keeps its independent 3.6. Do not conflate the two.
22
+ - The estimator is conservative (rounds up, adds framing) so the gate fires a
23
+ little early rather than a little late — overflowing the window is worse
24
+ than condensing one turn sooner than strictly necessary.
25
+
26
+ Port note: TS probed messages structurally (``isRecord`` + ``.role`` reads
27
+ over plain objects). Python transcripts are frozen dataclasses
28
+ (:mod:`indusagi.ai` messages plus the :mod:`indusagi.agent` custom kinds), so
29
+ the probing here is ``getattr`` duck typing over a ``role`` discriminator —
30
+ and the agent's custom messages (``bashExecution`` / ``custom`` /
31
+ ``branchSummary`` / ``compactionSummary``) get explicit branches because the
32
+ Python ``indusagi.ai.AgentMessage`` alias does not include them (analysis 05
33
+ risk-2): falling through to generic serialization for known roles would
34
+ silently mis-weigh them.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import dataclasses
40
+ import json
41
+ import math
42
+
43
+ from ..contract import AgentMessage
44
+
45
+ __all__ = [
46
+ "estimate_message_tokens",
47
+ "estimate_tokens",
48
+ "prefix_tokens",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Tunable constants (this module's own heuristic values)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ # Average serialized characters per token. English-ish text and JSON tend to
57
+ # pack a little under four characters per token for most tokenizers; we use a
58
+ # slightly conservative 3.6 so the estimate skews high. (App-OWN constant —
59
+ # the framework's estimator uses 4.0; keep them distinct.)
60
+ CHARS_PER_TOKEN = 3.6
61
+
62
+ # Flat token cost charged for one inline image block (resolution-agnostic).
63
+ IMAGE_TOKENS = 1024
64
+
65
+ # Tokens added per message to cover role/turn framing in the wire format.
66
+ MESSAGE_FRAMING_TOKENS = 4
67
+
68
+ # Tokens added per structured block (text/thinking/toolCall/toolResult part).
69
+ BLOCK_FRAMING_TOKENS = 2
70
+
71
+ # Tokens added per tool call to cover the call envelope (id + name framing).
72
+ TOOLCALL_ENVELOPE_TOKENS = 6
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Per-message serialization → character weight
77
+ # ---------------------------------------------------------------------------
78
+
79
+ # Sentinel distinguishing "attribute absent" from "attribute is None" — the
80
+ # Python analogue of the TS `=== undefined` probe.
81
+ _MISSING = object()
82
+
83
+
84
+ def _jsonable(value: object) -> object:
85
+ """``json.dumps`` fallback for non-JSON values: dataclasses flatten to
86
+ their field dict; anything else degrades to ``str()`` (we only ever
87
+ measure the serialized *length*, fidelity is irrelevant)."""
88
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
89
+ return dataclasses.asdict(value)
90
+ return str(value)
91
+
92
+
93
+ def _safe_json(value: object) -> str:
94
+ """JSON-serialize that never raises (cycles/exotic values → empty string).
95
+ Compact separators mirror ``JSON.stringify``'s no-spaces output so the
96
+ character weights stay comparable to the TS originals."""
97
+ try:
98
+ return json.dumps(value, default=_jsonable, separators=(",", ":"))
99
+ except Exception:
100
+ return ""
101
+
102
+
103
+ def _weigh_block(block: object) -> tuple[int, int]:
104
+ """Sum the character weight of a single content block and report whether
105
+ it was an image (images are billed as a flat token cost, not by character
106
+ length).
107
+
108
+ Returns ``(chars, images)``: ``chars`` feeds the chars-per-token divisor;
109
+ ``images`` is a count multiplied by :data:`IMAGE_TOKENS` later.
110
+ """
111
+ if isinstance(block, str):
112
+ return (len(block), 0)
113
+
114
+ block_type = getattr(block, "type", None)
115
+ if block_type == "text":
116
+ text = getattr(block, "text", None)
117
+ return (len(text) if isinstance(text, str) else 0, 0)
118
+ if block_type == "thinking":
119
+ thinking = getattr(block, "thinking", None)
120
+ return (len(thinking) if isinstance(thinking, str) else 0, 0)
121
+ if block_type == "image":
122
+ # Flat cost; do NOT measure the (often base64) `data` length.
123
+ return (0, 1)
124
+ if block_type == "toolCall":
125
+ name = getattr(block, "name", None)
126
+ chars = len(name) if isinstance(name, str) else 0
127
+ arguments = getattr(block, "arguments", _MISSING)
128
+ if arguments is not _MISSING:
129
+ chars += len(_safe_json(arguments))
130
+ return (chars, 0)
131
+
132
+ # Unknown/custom block: fall back to measuring its serialized form.
133
+ return (len(_safe_json(block)), 0)
134
+
135
+
136
+ def _weigh_content(content: object) -> tuple[int, int, int]:
137
+ """Weigh a string-or-blocks ``content`` field: ``(chars, images,
138
+ framing)`` where framing counts one :data:`BLOCK_FRAMING_TOKENS` per
139
+ block (a bare string counts as one block)."""
140
+ chars = 0
141
+ images = 0
142
+ framing = 0
143
+ if isinstance(content, str):
144
+ chars += len(content)
145
+ framing += BLOCK_FRAMING_TOKENS
146
+ elif isinstance(content, (list, tuple)):
147
+ for block in content:
148
+ block_chars, block_images = _weigh_block(block)
149
+ chars += block_chars
150
+ images += block_images
151
+ framing += BLOCK_FRAMING_TOKENS
152
+ return (chars, images, framing)
153
+
154
+
155
+ def _weigh_message(message: AgentMessage) -> tuple[int, int, int]:
156
+ """Total raw character weight + image count + framing-token total for one
157
+ message, dispatched by ``role``. Truly unknown roles fall through to a
158
+ generic serialization so they still contribute to the estimate.
159
+ """
160
+ chars = 0
161
+ images = 0
162
+ framing = MESSAGE_FRAMING_TOKENS
163
+
164
+ role = getattr(message, "role", None)
165
+
166
+ if role == "user":
167
+ content_chars, content_images, content_framing = _weigh_content(
168
+ getattr(message, "content", None)
169
+ )
170
+ return (chars + content_chars, images + content_images, framing + content_framing)
171
+
172
+ if role == "assistant":
173
+ content = getattr(message, "content", None)
174
+ if isinstance(content, (list, tuple)):
175
+ for block in content:
176
+ block_chars, block_images = _weigh_block(block)
177
+ chars += block_chars
178
+ images += block_images
179
+ framing += BLOCK_FRAMING_TOKENS
180
+ if getattr(block, "type", None) == "toolCall":
181
+ framing += TOOLCALL_ENVELOPE_TOKENS
182
+ error_message = getattr(message, "errorMessage", None)
183
+ if isinstance(error_message, str):
184
+ chars += len(error_message)
185
+ return (chars, images, framing)
186
+
187
+ if role == "toolResult":
188
+ tool_name = getattr(message, "toolName", None)
189
+ if isinstance(tool_name, str):
190
+ chars += len(tool_name)
191
+ content_chars, content_images, content_framing = _weigh_content(
192
+ getattr(message, "content", None)
193
+ )
194
+ chars += content_chars
195
+ images += content_images
196
+ framing += content_framing + TOOLCALL_ENVELOPE_TOKENS
197
+ return (chars, images, framing)
198
+
199
+ # --- agent custom session messages (EXPLICIT branches — the Python
200
+ # `indusagi.ai.AgentMessage` alias excludes these; see contract.py) -----
201
+
202
+ if role == "bashExecution":
203
+ command = getattr(message, "command", None)
204
+ output = getattr(message, "output", None)
205
+ if isinstance(command, str):
206
+ chars += len(command)
207
+ if isinstance(output, str):
208
+ chars += len(output)
209
+ return (chars, images, framing + BLOCK_FRAMING_TOKENS)
210
+
211
+ if role == "custom":
212
+ content_chars, content_images, content_framing = _weigh_content(
213
+ getattr(message, "content", None)
214
+ )
215
+ return (chars + content_chars, images + content_images, framing + content_framing)
216
+
217
+ if role in ("branchSummary", "compactionSummary"):
218
+ summary = getattr(message, "summary", None)
219
+ if isinstance(summary, str):
220
+ chars += len(summary)
221
+ return (chars, images, framing + BLOCK_FRAMING_TOKENS)
222
+
223
+ # Unrecognized role: serialize generically (matches the TS default arm).
224
+ chars += len(_safe_json(message))
225
+ return (chars, images, framing + BLOCK_FRAMING_TOKENS)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Public API
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def estimate_message_tokens(message: AgentMessage) -> int:
234
+ """Estimate the token cost of one message in isolation. Exposed so the
235
+ slice planner can build a prefix-sum without re-deriving the per-message
236
+ weight.
237
+
238
+ Cost = ``ceil(serialized_chars / CHARS_PER_TOKEN)`` + framing tokens +
239
+ ``images * IMAGE_TOKENS``. Always ``>= 0`` and biased high (conservative).
240
+ """
241
+ chars, images, framing = _weigh_message(message)
242
+ text_tokens = math.ceil(chars / CHARS_PER_TOKEN)
243
+ return text_tokens + framing + images * IMAGE_TOKENS
244
+
245
+
246
+ def estimate_tokens(messages: list[AgentMessage]) -> int:
247
+ """Estimate the total token cost of a transcript: the sum of every
248
+ message's :func:`estimate_message_tokens`. This is a pure heuristic — the
249
+ gate prefers a provider-anchored figure when one exists, and only falls
250
+ back to this.
251
+
252
+ Note: an empty transcript estimates to exactly ``0`` (no per-message
253
+ framing is charged when there are no messages).
254
+ """
255
+ total = 0
256
+ for message in messages:
257
+ total += estimate_message_tokens(message)
258
+ return total
259
+
260
+
261
+ def prefix_tokens(messages: list[AgentMessage]) -> list[int]:
262
+ """Build a FORWARD prefix-sum array of per-message token estimates.
263
+
264
+ Returns a list of length ``len(messages) + 1`` where ``result[i]`` is the
265
+ total estimated tokens of ``messages[0:i]`` (so ``result[0] == 0`` and
266
+ ``result[len(messages)] == estimate_tokens(messages)``). This
267
+ monotonically non-decreasing array is exactly what ``plan_slice``
268
+ binary-searches to find the cut boundary in O(log n).
269
+ """
270
+ prefix: list[int] = [0] * (len(messages) + 1)
271
+ for index, message in enumerate(messages):
272
+ prefix[index + 1] = prefix[index] + estimate_message_tokens(message)
273
+ return prefix
@@ -0,0 +1,60 @@
1
+ """Budget gate — the trigger predicate for the window-budget subsystem
2
+ (port of TS ``src/window-budget/budget/gate.ts``).
3
+
4
+ :func:`is_over_budget` answers the single question "is the transcript large
5
+ enough that we should condense before the next turn?" by comparing a token
6
+ estimate against a window-relative threshold derived from the model's
7
+ ``contextWindow`` and the caller-supplied
8
+ :class:`~induscode.window_budget.contract.BudgetPolicy`.
9
+
10
+ The threshold is computed as::
11
+
12
+ limit = (contextWindow - reserve_tokens?) * trigger_ratio
13
+
14
+ i.e. optional fixed headroom is carved off the window first, then the
15
+ remaining capacity is scaled by the window-relative trigger ratio. This keeps
16
+ the trigger free of baked-in token counts — ``trigger_ratio`` lives in
17
+ ``(0, 1]`` and ``reserve_tokens`` is an optional config value, never a
18
+ fingerprint constant.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ..contract import AgentMessage, BudgetPolicy, Model
24
+ from .estimate import estimate_tokens
25
+
26
+ __all__ = [
27
+ "budget_limit",
28
+ "is_over_budget",
29
+ ]
30
+
31
+
32
+ def budget_limit(model: Model, policy: BudgetPolicy) -> float:
33
+ """Compute the absolute token limit at which condensing should fire for a
34
+ given model and policy. Exposed for callers that want the number itself
35
+ (e.g. to display headroom) rather than just the boolean.
36
+
37
+ ``reserve_tokens`` (when present) is subtracted from the window before
38
+ the ratio is applied; the result is floored at ``0`` so a misconfigured
39
+ reserve larger than the window can never yield a negative limit.
40
+ """
41
+ reserve = policy.reserve_tokens if policy.reserve_tokens is not None else 0
42
+ usable = max(0, model.contextWindow - reserve)
43
+ return usable * policy.trigger_ratio
44
+
45
+
46
+ def is_over_budget(
47
+ messages: list[AgentMessage],
48
+ model: Model,
49
+ policy: BudgetPolicy,
50
+ ) -> bool:
51
+ """Decide whether the transcript is over budget for the given model and
52
+ policy.
53
+
54
+ Uses the heuristic :func:`~.estimate.estimate_tokens` over the serialized
55
+ transcript and compares it to :func:`budget_limit`. Returns ``True`` once
56
+ the estimate strictly exceeds the limit — the signal the conductor uses
57
+ to kick off a condense.
58
+ """
59
+ estimate = estimate_tokens(messages)
60
+ return estimate > budget_limit(model, policy)
@@ -0,0 +1,145 @@
1
+ """Slice planner — decides *where* to cut the transcript when it is over
2
+ budget (port of TS ``src/window-budget/budget/slice.ts``).
3
+
4
+ :func:`plan_slice` partitions a transcript into a ``dropped`` prefix (older
5
+ messages destined for a summary) and a ``kept`` suffix (the verbatim recent
6
+ tail), choosing the boundary so the tail holds roughly ``policy.keep_recent``
7
+ tokens. It does this with a FORWARD prefix-sum + BINARY SEARCH (not a
8
+ backward accumulate-and-snap):
9
+
10
+ 1. Build the cumulative-token array ``prefix`` (forward, monotonic).
11
+ 2. The total is ``prefix[n]``; we want the tail to be ~``keep_recent``
12
+ tokens, so the *ideal* cut sits at the first index ``i`` whose suffix
13
+ ``prefix[n] - prefix[i]`` has fallen to ``<= keep_recent``. Equivalently,
14
+ the first ``i`` with ``prefix[i] >= prefix[n] - keep_recent``. That
15
+ predicate is monotonic in ``i``, so we BINARY-SEARCH the lower bound of
16
+ the threshold ``prefix[n] - keep_recent`` over ``prefix``.
17
+ 3. Snap the resulting index FORWARD to the nearest *legal* boundary so we
18
+ never land between a tool_call and its matching tool_result. A boundary is
19
+ legal iff the message it lands on is not a ``toolResult`` (a tool result
20
+ always belongs with the assistant turn that issued the call, so the tail
21
+ may never begin with one).
22
+
23
+ If the whole transcript already fits within ``keep_recent``, or snapping
24
+ forward consumes everything, the cut is ``0`` (nothing condensable — keep it
25
+ all).
26
+
27
+ Port note: role probing is ``getattr`` duck typing over the message
28
+ dataclasses (the TS structural ``isRecord``/``.role`` reads), so the agent's
29
+ custom session messages — which carry roles like ``bashExecution`` /
30
+ ``branchSummary`` — are all legal tail starts; only ``toolResult`` is glued
31
+ to its issuing assistant turn.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from ..contract import AgentMessage, BudgetPolicy, CondensePlan
37
+ from .estimate import prefix_tokens
38
+
39
+ __all__ = [
40
+ "plan_slice",
41
+ ]
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Boundary legality
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def _is_legal_tail_start(message: AgentMessage) -> bool:
50
+ """A ``toolResult`` message must stay glued to the assistant turn that
51
+ issued its ``toolCall``. So the *kept tail* may never begin with a tool
52
+ result — that would orphan it from the (dropped) call. Every other role
53
+ is a legal tail-start.
54
+
55
+ :param message: the message that would become ``kept[0]`` for a candidate cut
56
+ :returns: ``True`` if a cut landing on this message is legal
57
+ """
58
+ return getattr(message, "role", None) != "toolResult"
59
+
60
+
61
+ def _snap_forward(messages: list[AgentMessage], candidate: int) -> int:
62
+ """Snap a candidate cut index FORWARD to the nearest legal boundary:
63
+ advance past any leading ``toolResult`` messages so the kept tail starts
64
+ on a user/assistant/custom turn, never mid tool-call group.
65
+
66
+ :returns: the snapped index in ``[candidate, len(messages)]``
67
+ """
68
+ cut = candidate
69
+ while cut < len(messages) and not _is_legal_tail_start(messages[cut]):
70
+ cut += 1
71
+ return cut
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Forward prefix-sum + binary search
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _lower_bound(prefix: list[int], threshold: float) -> int:
80
+ """Binary-search the lower bound: the smallest index ``i`` in
81
+ ``[0, len(prefix))`` with ``prefix[i] >= threshold``. ``prefix`` is
82
+ monotonically non-decreasing, so the predicate flips exactly once and a
83
+ standard half-open bisection finds it in O(log n). Returns
84
+ ``len(prefix) - 1`` if no index satisfies it.
85
+ """
86
+ lo = 0
87
+ hi = len(prefix) - 1
88
+ while lo < hi:
89
+ mid = (lo + hi) // 2
90
+ if prefix[mid] >= threshold:
91
+ hi = mid
92
+ else:
93
+ lo = mid + 1
94
+ return lo
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Public API
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def plan_slice(messages: list[AgentMessage], policy: BudgetPolicy) -> CondensePlan:
103
+ """Plan the condense slice for a transcript under a
104
+ :class:`~induscode.window_budget.contract.BudgetPolicy`.
105
+
106
+ Chooses the cut via forward prefix-sum + binary search for the boundary
107
+ that keeps ~``policy.keep_recent`` recent tokens, then snaps that
108
+ boundary forward to a legal point (never between a tool_call and its
109
+ tool_result).
110
+
111
+ :returns: a :class:`~induscode.window_budget.contract.CondensePlan` with
112
+ the chosen ``cut``, the ``kept`` tail (``messages[cut:]``) and the
113
+ ``dropped`` head (``messages[:cut]``). ``cut == 0`` when nothing is
114
+ condensable.
115
+ """
116
+ n = len(messages)
117
+ if n == 0:
118
+ return CondensePlan(cut=0, kept=[], dropped=[])
119
+
120
+ # Forward cumulative-token array: prefix[i] = tokens of messages[0:i].
121
+ prefix = prefix_tokens(messages)
122
+ total = prefix[n]
123
+
124
+ # If the whole transcript already fits in the recent-tail budget, keep all.
125
+ if total <= policy.keep_recent:
126
+ return CondensePlan(cut=0, kept=list(messages), dropped=[])
127
+
128
+ # Ideal cut: first index whose suffix has shrunk to <= keep_recent, i.e.
129
+ # the lower bound of (total - keep_recent) over the forward prefix array.
130
+ threshold = total - policy.keep_recent
131
+ ideal = _lower_bound(prefix, threshold)
132
+
133
+ # Snap forward to a legal boundary (don't orphan a tool result from its call).
134
+ cut = _snap_forward(messages, ideal)
135
+
136
+ # Snapping forward can swallow everything (e.g. a long trailing
137
+ # tool-result run): then there is nothing to drop — keep all.
138
+ if cut >= n:
139
+ return CondensePlan(cut=0, kept=list(messages), dropped=[])
140
+
141
+ return CondensePlan(
142
+ cut=cut,
143
+ kept=list(messages[cut:]),
144
+ dropped=list(messages[:cut]),
145
+ )