turnloop 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 (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,441 @@
1
+ """Context compaction, in three escalating tiers.
2
+
3
+ tier 1 micro-compaction free, always on, no model call
4
+ tier 2 thinking drop free, at 60% pressure
5
+ tier 3 full summarization one model call, at 75% pressure
6
+
7
+ Tiers 1 and 2 cost nothing and typically reclaim 30-50% of a tool-heavy session,
8
+ which is why they run first: a summarization call that could have been avoided
9
+ costs money, latency, and fidelity.
10
+
11
+ The invariant this module must never violate: **a `tool_use` block and its
12
+ `tool_result` are never separated.** An orphaned `tool_use` is a hard 400 from
13
+ Anthropic, OpenAI and Gemini alike, and it happens exactly when the context is
14
+ already under pressure — the worst possible moment to lose a turn. It has its own
15
+ property test.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Literal
22
+
23
+ from turnloop.config import CompactionConfig
24
+ from turnloop.core.events import CompactionHappened
25
+ from turnloop.core.messages import (
26
+ ContentBlock,
27
+ Message,
28
+ TextBlock,
29
+ ThinkingBlock,
30
+ ToolResultBlock,
31
+ ToolUseBlock,
32
+ )
33
+ from turnloop.core.tokens import history_tokens, rough_tokens
34
+ from turnloop.sessions.models import Session
35
+
36
+ Tier = Literal["micro", "thinking", "full", "hard"]
37
+
38
+ SUMMARY_PROMPT = """\
39
+ Summarize the conversation so far so that another instance of you can continue \
40
+ the work with no other context. Be specific and concrete; this summary replaces \
41
+ the transcript.
42
+
43
+ Structure your answer under these headings:
44
+
45
+ 1. Goal — what the user asked for, in their terms, including any constraint they
46
+ stated explicitly.
47
+ 2. Decisions — what was decided and, importantly, what was considered and
48
+ rejected, with the reason. Without this the next instance re-proposes ideas
49
+ that were already turned down.
50
+ 3. Files — every file read or modified, with the path and what changed in it.
51
+ 4. State — what is working, what is not, and what has been verified versus
52
+ assumed.
53
+ 5. Next step — the exact next action, specific enough to start immediately.
54
+ 6. Open questions — anything unresolved or awaiting the user.
55
+
56
+ Do not include tool output verbatim. Do not pad. Facts only.
57
+ """
58
+
59
+ SUMMARY_HEADER = "[Summary of the earlier conversation]"
60
+ MANIFEST_HEADER = "[Files touched so far]"
61
+ TRUNCATION_NOTE = "[truncated: {n} tokens elided. Re-run the tool if you need this again.]"
62
+ SUPERSEDED_NOTE = "[superseded by a later read of this file]"
63
+
64
+
65
+ @dataclass
66
+ class CompactionResult:
67
+ tier: Tier | None
68
+ tokens_before: int
69
+ tokens_after: int
70
+ summary: str = ""
71
+ replaced: tuple[int, int] = (0, 0)
72
+
73
+ @property
74
+ def happened(self) -> bool:
75
+ return self.tier is not None
76
+
77
+ @property
78
+ def reclaimed(self) -> int:
79
+ return self.tokens_before - self.tokens_after
80
+
81
+
82
+ @dataclass
83
+ class Compactor:
84
+ config: CompactionConfig
85
+ max_context: int
86
+ provider: object | None = None # a Provider; only needed for tier 3
87
+ system: list[str] = field(default_factory=list)
88
+ ui: object | None = None
89
+ strategy: Literal["summarize", "truncate", "micro_only", "none"] = "summarize"
90
+
91
+ # --- thresholds --------------------------------------------------------
92
+
93
+ @property
94
+ def tool_result_cap(self) -> int:
95
+ """Tighter cap on small windows.
96
+
97
+ 800 tokens of tool output is generous when the whole window is 57k of
98
+ usable space, and stingy when it is 190k. Scaling it is the difference
99
+ between compacting twice a session and compacting every other turn.
100
+ """
101
+ if self.max_context <= self.config.small_context_threshold:
102
+ return self.config.small_max_tool_result_tokens
103
+ return self.config.max_tool_result_tokens
104
+
105
+ def budget_available(self, system_tokens: int, tools_tokens: int,
106
+ max_output: int = 8_192) -> int:
107
+ from turnloop.context.budget import output_reserve
108
+
109
+ reserve = output_reserve(self.config.reserve_output_tokens, max_output)
110
+ return max(1_000, self.max_context - reserve - system_tokens - tools_tokens)
111
+
112
+ # --- entry point -------------------------------------------------------
113
+
114
+ async def maybe_compact(self, session: Session, available: int) -> CompactionResult:
115
+ """Run whichever tiers the current pressure calls for.
116
+
117
+ Called before *every* request, not on a timer: pressure is a property of
118
+ what just happened, and one large tool result can cross two thresholds in
119
+ a single turn.
120
+ """
121
+ before = history_tokens(session.messages)
122
+ if self.strategy == "none" or not self.config.enabled:
123
+ return CompactionResult(None, before, before)
124
+
125
+ applied: Tier | None = None
126
+
127
+ if self._micro_compact(session):
128
+ applied = "micro"
129
+
130
+ current = history_tokens(session.messages)
131
+ pressure = current / available
132
+
133
+ if pressure >= self.config.thinking_drop_pressure and self._drop_thinking(session):
134
+ applied = "thinking"
135
+ current = history_tokens(session.messages)
136
+ pressure = current / available
137
+
138
+ if self.strategy == "micro_only":
139
+ return await self._finish(session, applied, before, current)
140
+
141
+ if pressure >= self.config.full_pressure:
142
+ result = await self._full_compact(session, available)
143
+ if result.happened:
144
+ return await self._finish(
145
+ session, "full", before, result.tokens_after, result.summary, result.replaced
146
+ )
147
+
148
+ # Last-resort guard, deliberately outside the tier ladder. Summarization
149
+ # cannot help when the excess is a *single* message — there is no safe cut
150
+ # point in a four-message history whose third message is 500k characters —
151
+ # and sending a request known to exceed the window fails with a 400 and
152
+ # loses the turn. Truncating the body is strictly better than that.
153
+ if history_tokens(session.messages) > available:
154
+ _hard_truncate(session, available)
155
+ return await self._finish(
156
+ session, "hard", before, history_tokens(session.messages)
157
+ )
158
+
159
+ return await self._finish(session, applied, before, current)
160
+
161
+ async def _finish(self, session: Session, tier: Tier | None, before: int, after: int,
162
+ summary: str = "", replaced: tuple[int, int] = (0, 0)) -> CompactionResult:
163
+ result = CompactionResult(tier, before, after, summary, replaced)
164
+ if tier is not None:
165
+ session.compactions += 1
166
+ store = getattr(session, "store", None)
167
+ if store is not None:
168
+ store.write_compaction(tier, summary, replaced, before, after)
169
+ if self.ui is not None:
170
+ await self.ui.send(CompactionHappened(tier, before, after)) # type: ignore[attr-defined]
171
+ return result
172
+
173
+ # --- tier 1: micro-compaction -----------------------------------------
174
+
175
+ def _micro_compact(self, session: Session) -> bool:
176
+ """Shrink old, large tool results in place. No model call.
177
+
178
+ Two transformations, both safe because the model can always re-run a tool:
179
+ oversized old results are head+tail truncated, and superseded reads of the
180
+ same file collapse to a one-line marker.
181
+ """
182
+ messages = session.messages
183
+ keep_from = max(0, len(messages) - self.config.keep_recent_turns * 2)
184
+ changed = False
185
+ cap = self.tool_result_cap
186
+
187
+ # Later reads of a path make earlier ones redundant.
188
+ latest_read_of: dict[str, int] = {}
189
+ for i, msg in enumerate(messages):
190
+ for block in msg.content:
191
+ if isinstance(block, ToolUseBlock) and block.name in ("Read", "Glob", "Grep"):
192
+ path = str(block.args.get("file_path") or block.args.get("pattern") or "")
193
+ if path and block.name == "Read":
194
+ latest_read_of[path] = i
195
+
196
+ pending_supersede: set[str] = set()
197
+ for i, msg in enumerate(messages):
198
+ if i >= keep_from:
199
+ break
200
+ for block in msg.content:
201
+ if isinstance(block, ToolUseBlock) and block.name == "Read":
202
+ path = str(block.args.get("file_path") or "")
203
+ if path and latest_read_of.get(path, i) > i:
204
+ pending_supersede.add(block.id)
205
+
206
+ for i, msg in enumerate(messages):
207
+ if i >= keep_from:
208
+ break
209
+ for block in msg.content:
210
+ if not isinstance(block, ToolResultBlock) or block.truncated:
211
+ continue
212
+
213
+ if block.tool_use_id in pending_supersede and not block.is_error:
214
+ block.content = SUPERSEDED_NOTE
215
+ block.truncated = True
216
+ changed = True
217
+ continue
218
+
219
+ tokens = rough_tokens(block.content)
220
+ if tokens <= cap:
221
+ continue
222
+ block.content = _head_tail(block.content, cap)
223
+ block.truncated = True
224
+ changed = True
225
+
226
+ return changed
227
+
228
+ # --- tier 2: drop thinking --------------------------------------------
229
+
230
+ def _drop_thinking(self, session: Session) -> bool:
231
+ """Remove reasoning from all but the most recent turns.
232
+
233
+ Reasoning is high-value while a turn is live and near-worthless two turns
234
+ later; for OpenAI-compatible providers it was never resent anyway, so on
235
+ those this is pure profit. On Anthropic it forfeits some cache reuse,
236
+ which is an acceptable trade at 60% pressure.
237
+ """
238
+ messages = session.messages
239
+ keep_from = max(0, len(messages) - 4)
240
+ changed = False
241
+ for i, msg in enumerate(messages):
242
+ if i >= keep_from:
243
+ break
244
+ kept: list[ContentBlock] = [
245
+ b for b in msg.content if not isinstance(b, ThinkingBlock)
246
+ ]
247
+ if len(kept) != len(msg.content):
248
+ # Never empty a message entirely; an empty assistant turn is
249
+ # rejected by some providers.
250
+ msg.content = kept or [TextBlock(text="(reasoning elided)")]
251
+ changed = True
252
+ return changed
253
+
254
+ # --- tier 3: summarize ------------------------------------------------
255
+
256
+ async def _full_compact(self, session: Session, available: int) -> CompactionResult:
257
+ messages = session.messages
258
+ before = history_tokens(messages)
259
+ cut = find_cut_point(messages, available, self.config.keep_recent_turns)
260
+ if cut <= 0:
261
+ return CompactionResult(None, before, before)
262
+
263
+ prefix, tail = messages[:cut], messages[cut:]
264
+
265
+ if self.strategy == "truncate" or self.provider is None:
266
+ summary = _mechanical_summary(prefix)
267
+ else:
268
+ summary = await self._summarize(prefix)
269
+
270
+ preserved: list[str] = [f"{SUMMARY_HEADER}\n{summary}"]
271
+ if todo := session.todo_summary():
272
+ # Todos are the model's own plan. Losing them mid-task is the most
273
+ # visible compaction failure there is, so they are carried verbatim.
274
+ preserved.append(f"[Current task list]\n{todo}")
275
+ if manifest := _file_manifest(prefix):
276
+ preserved.append(f"{MANIFEST_HEADER}\n{manifest}")
277
+
278
+ synthetic = Message.user_text("\n\n".join(preserved), compaction=True)
279
+ session.replace_history([synthetic, *tail])
280
+
281
+ after = history_tokens(session.messages)
282
+ if after > available:
283
+ _hard_truncate(session, available)
284
+ after = history_tokens(session.messages)
285
+
286
+ return CompactionResult("full", before, after, summary, (0, cut))
287
+
288
+ async def _summarize(self, prefix: list[Message]) -> str:
289
+ from turnloop.core.events import MessageDone
290
+ from turnloop.providers.base import CompletionRequest
291
+
292
+ request = CompletionRequest(
293
+ messages=[*prefix, Message.user_text(SUMMARY_PROMPT)],
294
+ system=["You are summarizing a coding session for your own successor."],
295
+ tools=[],
296
+ max_tokens=2_000,
297
+ temperature=0.0,
298
+ )
299
+ try:
300
+ async for event in self.provider.stream(request): # type: ignore[attr-defined]
301
+ if isinstance(event, MessageDone):
302
+ text = event.message.text.strip()
303
+ if text:
304
+ return text
305
+ except Exception as exc: # noqa: BLE001
306
+ # A failed summarization must not fail the turn — fall back to the
307
+ # mechanical summary, which is worse but always works.
308
+ return _mechanical_summary(prefix) + f"\n\n(summarization failed: {exc})"
309
+ return _mechanical_summary(prefix)
310
+
311
+
312
+ # --------------------------------------------------------------------------
313
+ # helpers
314
+ # --------------------------------------------------------------------------
315
+
316
+
317
+ def find_cut_point(messages: list[Message], available: int, keep_recent_turns: int) -> int:
318
+ """Index to cut history at, never splitting a tool_use/tool_result pair.
319
+
320
+ Walks backwards accumulating tokens until the retained tail is at least 25% of
321
+ the usable window, then snaps the boundary to a safe position: a `user`
322
+ message that is not carrying tool results. That is the only place in the
323
+ transcript where nothing is mid-exchange.
324
+ """
325
+ if len(messages) <= 2:
326
+ return 0
327
+
328
+ target_tail = max(1, int(available * 0.25))
329
+ total = 0
330
+ cut = len(messages)
331
+ for i in range(len(messages) - 1, -1, -1):
332
+ total += history_tokens([messages[i]])
333
+ cut = i
334
+ if total >= target_tail:
335
+ break
336
+
337
+ # Always keep the most recent exchanges regardless of the token maths.
338
+ cut = min(cut, max(0, len(messages) - keep_recent_turns * 2))
339
+ return snap_to_safe_boundary(messages, cut)
340
+
341
+
342
+ def snap_to_safe_boundary(messages: list[Message], cut: int) -> int:
343
+ """Move `cut` earlier until it lands between complete exchanges."""
344
+ while cut > 0:
345
+ candidate = messages[cut]
346
+ starts_clean = candidate.role == "user" and not candidate.tool_results
347
+ previous_pending = bool(messages[cut - 1].tool_uses) and not starts_clean
348
+ if starts_clean and not previous_pending:
349
+ return cut
350
+ cut -= 1
351
+ return 0
352
+
353
+
354
+ def _head_tail(text: str, cap_tokens: int) -> str:
355
+ """Keep the beginning and the end of an oversized tool result."""
356
+ from turnloop.core.tokens import CHARS_PER_TOKEN
357
+
358
+ cap_chars = int(cap_tokens * CHARS_PER_TOKEN)
359
+ if len(text) <= cap_chars:
360
+ return text
361
+ head = int(cap_chars * 0.6)
362
+ tail = cap_chars - head
363
+ elided = rough_tokens(text) - cap_tokens
364
+ return text[:head] + "\n" + TRUNCATION_NOTE.format(n=elided) + "\n" + text[-tail:]
365
+
366
+
367
+ def _mechanical_summary(messages: list[Message]) -> str:
368
+ """A summary built without a model call.
369
+
370
+ Used when the strategy is `truncate`, when no provider is available, and as
371
+ the fallback if summarization fails. Deliberately factual: what was asked,
372
+ which tools ran, which files were touched.
373
+ """
374
+ user_asks = [m.text.strip() for m in messages if m.role == "user" and m.text.strip()]
375
+ tools: dict[str, int] = {}
376
+ for msg in messages:
377
+ for block in msg.tool_uses:
378
+ tools[block.name] = tools.get(block.name, 0) + 1
379
+
380
+ parts = [f"{len(messages)} earlier messages were removed to free context."]
381
+ if user_asks:
382
+ parts.append("The user asked, in order:")
383
+ parts += [f" - {' '.join(a.split())[:200]}" for a in user_asks[:8]]
384
+ if tools:
385
+ listed = ", ".join(f"{name} x{count}" for name, count in sorted(tools.items()))
386
+ parts.append(f"Tools used: {listed}.")
387
+ if manifest := _file_manifest(messages):
388
+ parts.append("Files touched:\n" + manifest)
389
+ return "\n".join(parts)
390
+
391
+
392
+ def _file_manifest(messages: list[Message]) -> str:
393
+ """Paths touched, and whether they were modified.
394
+
395
+ Survives compaction verbatim because "which files am I working on" is the one
396
+ fact a summary cannot afford to paraphrase.
397
+ """
398
+ seen: dict[str, bool] = {}
399
+ for msg in messages:
400
+ for block in msg.tool_uses:
401
+ path = block.args.get("file_path")
402
+ if not isinstance(path, str) or not path:
403
+ continue
404
+ modified = block.name in ("Write", "Edit")
405
+ seen[path] = seen.get(path, False) or modified
406
+ if not seen:
407
+ return ""
408
+ return "\n".join(
409
+ f" - {path}{' (modified)' if modified else ''}" for path, modified in sorted(seen.items())
410
+ )
411
+
412
+
413
+ def _hard_truncate(session: Session, available: int) -> None:
414
+ """Last resort: shrink oversized message bodies directly.
415
+
416
+ Reached only when a single message is bigger than the whole window — a
417
+ pathological Read or a giant paste. Sending a request known to exceed the
418
+ context is never acceptable: it fails with a 400 and loses the turn.
419
+ """
420
+ messages = session.messages
421
+ while history_tokens(messages) > available and messages:
422
+ biggest = max(range(len(messages)), key=lambda i: history_tokens([messages[i]]))
423
+ msg = messages[biggest]
424
+ changed = False
425
+ for block in msg.content:
426
+ if isinstance(block, TextBlock | ToolResultBlock):
427
+ text = block.content if isinstance(block, ToolResultBlock) else block.text
428
+ if len(text) <= 400:
429
+ continue
430
+ new_text = _head_tail(text, max(100, rough_tokens(text) // 4))
431
+ if isinstance(block, ToolResultBlock):
432
+ block.content = new_text
433
+ block.truncated = True
434
+ else:
435
+ block.text = new_text
436
+ changed = True
437
+ if not changed:
438
+ if len(messages) <= 2:
439
+ break
440
+ messages.pop(0)
441
+ session.replace_history(messages)
@@ -0,0 +1,120 @@
1
+ """Project and user memory files.
2
+
3
+ Discovery order, nearest-last so the most specific instructions are read last and
4
+ therefore carry the most weight:
5
+
6
+ ~/.turnloop/TURNLOOP.md
7
+ <repo root>/TURNLOOP.md
8
+ ... every directory down to the working directory ...
9
+ <cwd>/TURNLOOP.md
10
+
11
+ `CLAUDE.md` and `AGENTS.md` are accepted under the same rules. Recognizing files
12
+ this project did not invent is not flattery — those files already exist in real
13
+ repositories and they contain exactly the instructions this harness needs.
14
+
15
+ Memory is capped. On a 65k window an enthusiastic 8k-token instruction file would
16
+ silently take an eighth of everything, so the cap warns rather than truncating in
17
+ silence.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from turnloop.core.tokens import rough_tokens
27
+
28
+ MEMORY_FILENAMES = ("TURNLOOP.md", "CLAUDE.md", "AGENTS.md")
29
+ IMPORT_RE = re.compile(r"^@([^\s]+)\s*$", re.MULTILINE)
30
+ MAX_IMPORT_DEPTH = 3
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class MemoryFile:
35
+ path: Path
36
+ content: str
37
+ scope: str # "user" | "project"
38
+
39
+ @property
40
+ def tokens(self) -> int:
41
+ return rough_tokens(self.content)
42
+
43
+
44
+ def discover_memory(cwd: Path, project_root: Path) -> list[MemoryFile]:
45
+ files: list[MemoryFile] = []
46
+
47
+ for name in MEMORY_FILENAMES:
48
+ user_path = Path.home() / ".turnloop" / name
49
+ if user_path.is_file():
50
+ files.append(MemoryFile(user_path, _load(user_path), "user"))
51
+ break
52
+
53
+ # Root down to cwd, so a subdirectory's file is read after its parent's.
54
+ chain: list[Path] = []
55
+ current = cwd.resolve()
56
+ root = project_root.resolve()
57
+ while True:
58
+ chain.append(current)
59
+ if current == root or current.parent == current:
60
+ break
61
+ current = current.parent
62
+ for directory in reversed(chain):
63
+ for name in MEMORY_FILENAMES:
64
+ path = directory / name
65
+ if path.is_file():
66
+ files.append(MemoryFile(path, _load(path), "project"))
67
+ break
68
+
69
+ return files
70
+
71
+
72
+ def _load(path: Path, depth: int = 0, seen: set[Path] | None = None) -> str:
73
+ """Read a memory file, resolving `@path` imports recursively."""
74
+ seen = seen or set()
75
+ resolved = path.resolve()
76
+ if resolved in seen or depth > MAX_IMPORT_DEPTH:
77
+ return ""
78
+ seen.add(resolved)
79
+
80
+ try:
81
+ text = path.read_text(encoding="utf-8", errors="replace")
82
+ except OSError:
83
+ return ""
84
+
85
+ def replace(match: re.Match[str]) -> str:
86
+ target = (path.parent / match.group(1)).expanduser()
87
+ if not target.is_file():
88
+ return f"(missing import: {match.group(1)})"
89
+ return _load(target, depth + 1, seen)
90
+
91
+ return IMPORT_RE.sub(replace, text)
92
+
93
+
94
+ def render_memory(files: list[MemoryFile], max_tokens: int = 4_000) -> str:
95
+ """Concatenate with provenance headers, capped.
96
+
97
+ Provenance matters: when the model follows an instruction the user forgot they
98
+ wrote, the transcript should show which file it came from.
99
+ """
100
+ if not files:
101
+ return ""
102
+
103
+ parts: list[str] = []
104
+ used = 0
105
+ dropped: list[str] = []
106
+
107
+ for memory in files:
108
+ if used + memory.tokens > max_tokens:
109
+ dropped.append(str(memory.path))
110
+ continue
111
+ used += memory.tokens
112
+ parts.append(f"--- from {memory.path} ({memory.scope}) ---\n{memory.content.strip()}")
113
+
114
+ rendered = "\n\n".join(parts)
115
+ if dropped:
116
+ rendered += (
117
+ f"\n\n[memory truncated: {', '.join(dropped)} omitted, "
118
+ f"{max_tokens} token budget reached]"
119
+ )
120
+ return rendered
File without changes