comodor 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 (70) hide show
  1. comodor/__init__.py +18 -0
  2. comodor/__main__.py +6 -0
  3. comodor/agent/__init__.py +9 -0
  4. comodor/agent/context.py +149 -0
  5. comodor/agent/loop.py +409 -0
  6. comodor/agent/prompts.py +209 -0
  7. comodor/agent/tokens.py +131 -0
  8. comodor/cli.py +295 -0
  9. comodor/config.py +394 -0
  10. comodor/events.py +211 -0
  11. comodor/learning/__init__.py +8 -0
  12. comodor/learning/bm25.py +147 -0
  13. comodor/learning/hotindex.py +228 -0
  14. comodor/learning/memory.py +450 -0
  15. comodor/learning/progress.py +184 -0
  16. comodor/learning/reflect.py +152 -0
  17. comodor/learning/rules.py +418 -0
  18. comodor/learning/signals.py +310 -0
  19. comodor/learning/store.py +992 -0
  20. comodor/learning/writer.py +185 -0
  21. comodor/net/__init__.py +11 -0
  22. comodor/net/http.py +2036 -0
  23. comodor/net/sse.py +113 -0
  24. comodor/paths.py +120 -0
  25. comodor/providers/__init__.py +24 -0
  26. comodor/providers/anthropic.py +267 -0
  27. comodor/providers/base.py +266 -0
  28. comodor/providers/fake.py +136 -0
  29. comodor/providers/gateway.py +275 -0
  30. comodor/providers/openai_compat.py +281 -0
  31. comodor/providers/registry.py +178 -0
  32. comodor/safety/__init__.py +11 -0
  33. comodor/safety/checkpoints.py +232 -0
  34. comodor/safety/permissions.py +199 -0
  35. comodor/safety/redact.py +92 -0
  36. comodor/session/__init__.py +5 -0
  37. comodor/session/store.py +203 -0
  38. comodor/tools/__init__.py +7 -0
  39. comodor/tools/base.py +173 -0
  40. comodor/tools/fs.py +314 -0
  41. comodor/tools/registry.py +85 -0
  42. comodor/tools/search.py +252 -0
  43. comodor/tools/shell.py +234 -0
  44. comodor/tools/todo.py +93 -0
  45. comodor/tools/web.py +174 -0
  46. comodor/ui/__init__.py +8 -0
  47. comodor/ui/app.py +1159 -0
  48. comodor/ui/console.py +125 -0
  49. comodor/ui/input/__init__.py +15 -0
  50. comodor/ui/input/keys.py +322 -0
  51. comodor/ui/input/reader.py +288 -0
  52. comodor/ui/layout.py +189 -0
  53. comodor/ui/markdown.py +76 -0
  54. comodor/ui/screen.py +185 -0
  55. comodor/ui/theme.py +251 -0
  56. comodor/ui/widgets/__init__.py +21 -0
  57. comodor/ui/widgets/buttons.py +108 -0
  58. comodor/ui/widgets/chat.py +236 -0
  59. comodor/ui/widgets/history.py +131 -0
  60. comodor/ui/widgets/overlay.py +246 -0
  61. comodor/ui/widgets/panel.py +91 -0
  62. comodor/ui/widgets/progress.py +143 -0
  63. comodor/ui/widgets/prompt.py +310 -0
  64. comodor/ui/widgets/statusbar.py +193 -0
  65. comodor/ui/widgets/toast.py +67 -0
  66. comodor-0.1.0.dist-info/METADATA +313 -0
  67. comodor-0.1.0.dist-info/RECORD +70 -0
  68. comodor-0.1.0.dist-info/WHEEL +4 -0
  69. comodor-0.1.0.dist-info/entry_points.txt +2 -0
  70. comodor-0.1.0.dist-info/licenses/LICENSE +21 -0
comodor/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Comodor — a self-improving terminal coding agent.
2
+
3
+ The package is layered so each piece can be used on its own:
4
+
5
+ comodor.net zero-dependency HTTP + SSE transport
6
+ comodor.providers LLM backends and the health-aware model gateway
7
+ comodor.agent the reason/act loop, context budgeting, prompts
8
+ comodor.tools the capabilities the agent can invoke
9
+ comodor.safety permissions, checkpoints, secret redaction
10
+ comodor.learning the persistent brain that makes it better over time
11
+ comodor.session conversation persistence and export
12
+ comodor.ui the Rich terminal interface
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+ APP_NAME = "Comodor"
17
+
18
+ __all__ = ["__version__", "APP_NAME"]
comodor/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m comodor``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ """The reasoning core: the loop, the context budget, and the prompts."""
2
+
3
+ from .context import Conversation
4
+ from .loop import AgentLoop, TurnResult
5
+ from .prompts import build_system_prompt
6
+ from .tokens import TokenCounter, humanise
7
+
8
+ __all__ = ["AgentLoop", "TurnResult", "Conversation", "build_system_prompt",
9
+ "TokenCounter", "humanise"]
@@ -0,0 +1,149 @@
1
+ """Conversation state and the context budget.
2
+
3
+ A long agent session will always outgrow its context window — tool output is
4
+ verbose and there is a lot of it. When usage crosses the configured fraction of
5
+ the window, the oldest middle section is replaced by an LLM-written brief.
6
+
7
+ The subtle part is *where* to cut. Every assistant message that requests tools
8
+ must keep its matching tool results, or the next request is rejected outright by
9
+ the provider. So compaction only ever cuts at a boundary where no tool call is
10
+ outstanding, and the original request is always preserved — losing the goal is
11
+ the one failure a summary cannot recover from.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Callable
18
+
19
+ from ..providers.base import Message, Role, ToolSpec, Usage
20
+ from .tokens import TokenCounter
21
+
22
+ Summariser = Callable[[list[Message]], str]
23
+
24
+
25
+ @dataclass
26
+ class Conversation:
27
+ """The message history plus everything we track about its size."""
28
+
29
+ messages: list[Message] = field(default_factory=list)
30
+ counter: TokenCounter = field(default_factory=TokenCounter)
31
+ usage: Usage = field(default_factory=Usage)
32
+ compactions: int = 0
33
+
34
+ # -- basics ----------------------------------------------------------- #
35
+
36
+ def add(self, message: Message) -> Message:
37
+ self.messages.append(message)
38
+ return message
39
+
40
+ def extend(self, messages: list[Message]) -> None:
41
+ self.messages.extend(messages)
42
+
43
+ def clear(self) -> None:
44
+ self.messages.clear()
45
+ self.usage = Usage()
46
+ self.compactions = 0
47
+
48
+ def render(self, system_prompt: str) -> list[Message]:
49
+ """The full payload for one request."""
50
+ return [Message.system(system_prompt), *self.messages]
51
+
52
+ @property
53
+ def last_user_text(self) -> str:
54
+ for message in reversed(self.messages):
55
+ if message.role is Role.USER:
56
+ return message.content
57
+ return ""
58
+
59
+ # -- accounting ------------------------------------------------------- #
60
+
61
+ def used_tokens(self, system_prompt: str = "", tools: list[ToolSpec] | None = None) -> int:
62
+ payload = self.render(system_prompt) if system_prompt else self.messages
63
+ return self.counter.count(payload, tools)
64
+
65
+ def record_usage(self, usage: Usage) -> None:
66
+ self.usage = self.usage.merge(usage)
67
+
68
+ def fill(self, limit: int, system_prompt: str = "",
69
+ tools: list[ToolSpec] | None = None) -> float:
70
+ """How full the context window is, as a fraction."""
71
+ if limit <= 0:
72
+ return 0.0
73
+ return min(1.0, self.used_tokens(system_prompt, tools) / limit)
74
+
75
+ # -- compaction ------------------------------------------------------- #
76
+
77
+ def needs_compaction(self, limit: int, threshold: float,
78
+ system_prompt: str = "",
79
+ tools: list[ToolSpec] | None = None) -> bool:
80
+ return self.fill(limit, system_prompt, tools) >= threshold
81
+
82
+ def safe_cut(self, keep_recent: int = 8) -> int:
83
+ """Index up to which messages may be summarised away.
84
+
85
+ A cut is only safe where the conversation is *settled*: a user turn
86
+ with no assistant tool call still awaiting its result. Returns 0 when
87
+ no safe point exists, which simply means compaction waits a turn.
88
+ """
89
+ if len(self.messages) <= keep_recent + 2:
90
+ return 0
91
+
92
+ latest_allowed = len(self.messages) - keep_recent
93
+ pending: set[str] = set()
94
+ last_safe = 0
95
+
96
+ for index, message in enumerate(self.messages):
97
+ if message.role is Role.ASSISTANT and message.tool_calls:
98
+ pending.update(call.id for call in message.tool_calls)
99
+ elif message.role is Role.TOOL:
100
+ pending.discard(message.tool_call_id)
101
+
102
+ # A user message with nothing outstanding is a clean seam.
103
+ if (index > 0 and not pending and message.role is Role.USER
104
+ and index <= latest_allowed):
105
+ last_safe = index
106
+
107
+ return last_safe
108
+
109
+ def compact(self, summarise: Summariser, keep_recent: int = 8) -> int:
110
+ """Replace the middle of the history with a brief. Returns messages removed."""
111
+ cut = self.safe_cut(keep_recent)
112
+ if cut <= 1:
113
+ return 0
114
+
115
+ head = self.messages[0] # the original request stays verbatim
116
+ middle = self.messages[1:cut]
117
+ tail = self.messages[cut:]
118
+ if not middle:
119
+ return 0
120
+
121
+ try:
122
+ brief = summarise(middle).strip()
123
+ except Exception:
124
+ # A failed summary must not lose messages; better a full context
125
+ # and a hard error later than silently discarded work now.
126
+ return 0
127
+ if not brief:
128
+ return 0
129
+
130
+ marker = Message(
131
+ role=Role.USER,
132
+ content=("[Earlier in this session — compacted summary]\n\n" + brief),
133
+ meta={"compacted": True, "replaced": len(middle)},
134
+ )
135
+ self.messages = [head, marker, *tail]
136
+ self.compactions += 1
137
+ return len(middle)
138
+
139
+ # -- introspection ---------------------------------------------------- #
140
+
141
+ def summary_line(self) -> str:
142
+ roles = {role: 0 for role in ("user", "assistant", "tool")}
143
+ for message in self.messages:
144
+ key = message.role.value
145
+ if key in roles:
146
+ roles[key] += 1
147
+ return (f"{len(self.messages)} messages "
148
+ f"({roles['user']} user, {roles['assistant']} assistant, "
149
+ f"{roles['tool']} tool)")
comodor/agent/loop.py ADDED
@@ -0,0 +1,409 @@
1
+ """The reason/act loop.
2
+
3
+ One turn is: assemble context, stream a response, run whatever tools it asked
4
+ for, feed the results back, repeat. It ends when the model stops asking for
5
+ tools, or when a guard trips — step count, wall clock, or spend.
6
+
7
+ Three details are worth knowing:
8
+
9
+ *Loop off means one step.* The ``Loop`` switch in the status bar decides whether
10
+ the agent may iterate after the first round of tools or must hand control back.
11
+
12
+ *Read-only tools run in parallel.* When a round contains only ``SAFE`` calls and
13
+ they need no approval, they execute concurrently — several file reads should not
14
+ cost several round trips of latency. Results are still reported in call order so
15
+ the transcript stays deterministic.
16
+
17
+ *Cancellation is cooperative and checked everywhere.* Between steps, between
18
+ stream chunks, and inside the shell tool, so Esc stops the agent within a
19
+ fraction of a second rather than at the end of the current model response.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from concurrent.futures import ThreadPoolExecutor
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Callable
29
+
30
+ from ..config import Config
31
+ from ..events import Cancellation, Cancelled, EventBus, Kind
32
+ from ..providers.base import (
33
+ EventType,
34
+ Message,
35
+ ProviderError,
36
+ ToolCall,
37
+ ToolSpec,
38
+ Usage,
39
+ )
40
+ from ..providers.gateway import Gateway
41
+ from ..safety import PermissionEngine, Risk
42
+ from ..tools import ToolContext, ToolRegistry, ToolResult
43
+ from .context import Conversation
44
+ from .prompts import COMPACT_PROMPT, build_system_prompt
45
+
46
+ MAX_PARALLEL_TOOLS = 6
47
+
48
+
49
+ @dataclass
50
+ class TurnResult:
51
+ """What one user turn produced."""
52
+
53
+ text: str = ""
54
+ steps: int = 0
55
+ tool_calls: int = 0
56
+ usage: Usage = field(default_factory=Usage)
57
+ stopped: str = "done" # done | max_steps | budget | cancelled | error
58
+ error: str = ""
59
+ elapsed: float = 0.0
60
+
61
+ @property
62
+ def ok(self) -> bool:
63
+ return self.stopped in ("done", "max_steps")
64
+
65
+
66
+ class AgentLoop:
67
+ """Drives one conversation against one gateway and one tool set."""
68
+
69
+ def __init__(self, config: Config, gateway: Gateway, tools: ToolRegistry,
70
+ bus: EventBus, permissions: PermissionEngine,
71
+ conversation: Conversation | None = None,
72
+ memory: Any = None) -> None:
73
+ self.config = config
74
+ self.gateway = gateway
75
+ self.tools = tools
76
+ self.bus = bus
77
+ self.permissions = permissions
78
+ self.conversation = conversation or Conversation()
79
+ self.memory = memory # LearningEngine, or None
80
+ self.cancel = Cancellation()
81
+ self.tool_context: ToolContext | None = None
82
+ self._recalled: list[Any] = []
83
+
84
+ # -- public API ------------------------------------------------------- #
85
+
86
+ def run(self, user_text: str, images: list[str] | None = None) -> TurnResult:
87
+ """Handle one user message from start to finish."""
88
+ started = time.monotonic()
89
+ self.cancel.reset()
90
+ result = TurnResult()
91
+
92
+ self.conversation.add(Message.user(user_text, images=images or []))
93
+ self.bus.emit(Kind.TURN_START, text=user_text)
94
+
95
+ playbook = self._recall(user_text)
96
+ deadline = started + self.config.agent.max_seconds
97
+
98
+ try:
99
+ result = self._iterate(playbook, deadline)
100
+ except Cancelled:
101
+ result.stopped = "cancelled"
102
+ self.bus.emit(Kind.CANCELLED)
103
+ except ProviderError as exc:
104
+ result.stopped = "error"
105
+ result.error = str(exc)
106
+ self.bus.emit(Kind.ERROR, text=str(exc))
107
+ except Exception as exc: # a bug here must not kill the UI
108
+ result.stopped = "error"
109
+ result.error = f"{type(exc).__name__}: {exc}"
110
+ self.bus.emit(Kind.ERROR, text=result.error)
111
+
112
+ result.elapsed = time.monotonic() - started
113
+ result.usage = self.conversation.usage
114
+ self.bus.emit(Kind.TURN_END, stopped=result.stopped, steps=result.steps,
115
+ elapsed=result.elapsed, error=result.error)
116
+
117
+ self._learn(user_text, result)
118
+ return result
119
+
120
+ def interrupt(self) -> None:
121
+ self.cancel.cancel()
122
+
123
+ # -- the loop --------------------------------------------------------- #
124
+
125
+ def _iterate(self, playbook: str, deadline: float) -> TurnResult:
126
+ result = TurnResult()
127
+ agent = self.config.agent
128
+
129
+ while True:
130
+ self.cancel.raise_if_cancelled()
131
+ result.steps += 1
132
+
133
+ specs = self.tools.specs(agent.mode)
134
+ system_prompt = build_system_prompt(self.config, playbook)
135
+ self._maybe_compact(system_prompt, specs)
136
+
137
+ completion = self._stream_once(system_prompt, specs)
138
+ assistant = completion["message"]
139
+ self.conversation.add(assistant)
140
+
141
+ calls: list[ToolCall] = assistant.tool_calls
142
+ if not calls:
143
+ result.text = assistant.content
144
+ result.stopped = "done"
145
+ return result
146
+
147
+ result.tool_calls += len(calls)
148
+ self._execute(calls)
149
+ self.bus.emit(Kind.STEP, step=result.steps, tool_calls=len(calls))
150
+
151
+ if not agent.loop:
152
+ # Loop off: run the tools the model asked for, then stop and
153
+ # let the user decide whether to continue.
154
+ result.stopped = "done"
155
+ result.text = assistant.content
156
+ return result
157
+
158
+ if result.steps >= agent.max_steps:
159
+ result.stopped = "max_steps"
160
+ self._note(f"Stopped after {agent.max_steps} steps — the step limit "
161
+ f"for one task. Say 'continue' to keep going.")
162
+ return result
163
+
164
+ if time.monotonic() > deadline:
165
+ result.stopped = "budget"
166
+ self._note(f"Stopped after {agent.max_seconds:.0f}s — the time limit "
167
+ f"for one task.")
168
+ return result
169
+
170
+ spent = self.conversation.usage.cost_usd
171
+ if agent.max_cost_usd and spent >= agent.max_cost_usd:
172
+ result.stopped = "budget"
173
+ self._note(f"Stopped at ${spent:.2f} — the spend limit for one task.")
174
+ return result
175
+
176
+ # -- model call ------------------------------------------------------- #
177
+
178
+ def _stream_once(self, system_prompt: str, specs: list[ToolSpec]) -> dict[str, Any]:
179
+ """One streamed assistant response, surfaced to the UI as it arrives."""
180
+ payload = self.conversation.render(system_prompt)
181
+ agent = self.config.agent
182
+
183
+ self.bus.emit(Kind.ASSISTANT_START)
184
+ text_parts: list[str] = []
185
+ reasoning_parts: list[str] = []
186
+ tool_calls: list[ToolCall] = []
187
+ usage = Usage()
188
+
189
+ stream = self.gateway.stream(
190
+ payload,
191
+ tools=specs or None,
192
+ model=self.config.model,
193
+ temperature=agent.temperature,
194
+ max_tokens=agent.max_output_tokens,
195
+ )
196
+
197
+ for event in stream:
198
+ self.cancel.raise_if_cancelled()
199
+ if event.type is EventType.TEXT:
200
+ text_parts.append(event.text)
201
+ self.bus.emit(Kind.ASSISTANT_DELTA, text=event.text)
202
+ elif event.type is EventType.REASONING:
203
+ reasoning_parts.append(event.text)
204
+ self.bus.emit(Kind.REASONING_DELTA, text=event.text)
205
+ elif event.type is EventType.TOOL_CALL and event.tool_call:
206
+ tool_calls.append(event.tool_call)
207
+ elif event.type is EventType.USAGE and event.usage:
208
+ usage = usage.merge(event.usage)
209
+
210
+ text = "".join(text_parts)
211
+ self.bus.emit(Kind.ASSISTANT_END, text=text,
212
+ tool_calls=[call.name for call in tool_calls])
213
+
214
+ self.conversation.record_usage(usage)
215
+ if usage.input_tokens:
216
+ self.conversation.counter.observe_usage(payload, specs, usage.input_tokens)
217
+ self._emit_usage(system_prompt, specs)
218
+
219
+ message = Message.assistant(text, tool_calls)
220
+ if reasoning_parts:
221
+ message.meta["reasoning"] = "".join(reasoning_parts)
222
+ route = self.gateway.last_route
223
+ if route:
224
+ message.meta["provider"] = route.provider
225
+ message.meta["model"] = route.model
226
+ if route.failed_over_from:
227
+ self._note(f"{', '.join(route.failed_over_from)} failed — "
228
+ f"served by {route.provider}.")
229
+ return {"message": message, "usage": usage}
230
+
231
+ # -- tools ------------------------------------------------------------ #
232
+
233
+ def _execute(self, calls: list[ToolCall]) -> None:
234
+ context = self._tool_context()
235
+ parallel = self._can_parallelise(calls)
236
+
237
+ if parallel and len(calls) > 1:
238
+ with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_TOOLS, len(calls))) as pool:
239
+ futures = [pool.submit(self._run_one, call, context) for call in calls]
240
+ results = [future.result() for future in futures]
241
+ else:
242
+ results = [self._run_one(call, context) for call in calls]
243
+
244
+ for call, result in zip(calls, results):
245
+ self.conversation.add(Message.tool(
246
+ call_id=call.id, name=call.name,
247
+ content=result.content, is_error=not result.ok,
248
+ ))
249
+
250
+ def _run_one(self, call: ToolCall, context: ToolContext) -> ToolResult:
251
+ self.bus.emit(Kind.TOOL_START, id=call.id, name=call.name,
252
+ arguments=call.arguments,
253
+ summary=self._describe(call))
254
+ if self.cancel.cancelled:
255
+ result = ToolResult.failure("cancelled before the tool ran")
256
+ else:
257
+ result = self.tools.invoke(call.name, context, call.arguments)
258
+ self.bus.emit(Kind.TOOL_END, id=call.id, name=call.name, ok=result.ok,
259
+ content=result.content, display=result.rendered,
260
+ elapsed=result.elapsed, meta=result.meta)
261
+ return result
262
+
263
+ def _describe(self, call: ToolCall) -> str:
264
+ tool = self.tools.get(call.name)
265
+ return tool.summary(call.arguments) if tool else call.name
266
+
267
+ def _can_parallelise(self, calls: list[ToolCall]) -> bool:
268
+ """Only when nothing in the batch could stop to ask a question."""
269
+ if not self.config.safety.auto_approve_safe:
270
+ return False
271
+ for call in calls:
272
+ tool = self.tools.get(call.name)
273
+ if tool is None or tool.risk is not Risk.SAFE:
274
+ return False
275
+ return True
276
+
277
+ def _tool_context(self) -> ToolContext:
278
+ if self.tool_context is None:
279
+ from ..safety import CheckpointStore, Redactor
280
+
281
+ secrets = [entry.api_key for entry in self.config.providers.values()
282
+ if entry.api_key]
283
+ self.tool_context = ToolContext(
284
+ config=self.config,
285
+ permissions=self.permissions,
286
+ checkpoints=CheckpointStore(self.config.paths.checkpoints),
287
+ bus=self.bus,
288
+ redact=Redactor(secrets),
289
+ cancel=self.cancel,
290
+ cwd=Path(self.config.paths.project),
291
+ emit_output=lambda text: self.bus.emit(Kind.TOOL_OUTPUT, text=text),
292
+ )
293
+ return self.tool_context
294
+
295
+ # -- context management ----------------------------------------------- #
296
+
297
+ def _maybe_compact(self, system_prompt: str, specs: list[ToolSpec]) -> None:
298
+ agent = self.config.agent
299
+ limit = agent.context_limit or 128_000
300
+ if not self.conversation.needs_compaction(limit, agent.compact_at,
301
+ system_prompt, specs):
302
+ return
303
+
304
+ removed = self.conversation.compact(self._summarise)
305
+ if removed:
306
+ self._note(f"Compacted {removed} earlier messages to free context.")
307
+ self._emit_usage(system_prompt, specs)
308
+
309
+ def _summarise(self, messages: list[Message]) -> str:
310
+ """Ask the model to write the brief that replaces old history."""
311
+ from ..providers.base import collapse
312
+
313
+ transcript = "\n\n".join(
314
+ f"[{message.role.value}] {message.content[:2000]}"
315
+ for message in messages if message.content
316
+ )
317
+ completion = collapse(self.gateway.stream(
318
+ [Message.system(COMPACT_PROMPT), Message.user(transcript)],
319
+ model=self.config.model, temperature=0.2, max_tokens=1500,
320
+ ))
321
+ return completion.text
322
+
323
+ def _emit_usage(self, system_prompt: str, specs: list[ToolSpec]) -> None:
324
+ limit = self.config.agent.context_limit or 128_000
325
+ used = self.conversation.used_tokens(system_prompt, specs)
326
+ usage = self.conversation.usage
327
+ self.bus.emit(
328
+ Kind.USAGE,
329
+ context_used=used,
330
+ context_limit=limit,
331
+ fill=min(1.0, used / limit) if limit else 0.0,
332
+ input_tokens=usage.input_tokens,
333
+ output_tokens=usage.output_tokens,
334
+ cost_usd=usage.cost_usd,
335
+ )
336
+
337
+ def _note(self, text: str) -> None:
338
+ self.bus.emit(Kind.NOTICE, text=text)
339
+
340
+ # -- learning --------------------------------------------------------- #
341
+
342
+ def _recall(self, user_text: str) -> str:
343
+ """Apply anything the user changed, then build the memory block.
344
+
345
+ The order matters. Corrections are folded in *before* recall, so a fix
346
+ the user made a minute ago governs the answer they are about to get,
347
+ rather than the one after that.
348
+ """
349
+ if self.memory is None or not self.config.learning.enabled:
350
+ return ""
351
+
352
+ try:
353
+ self.memory.before_turn(user_text)
354
+ except Exception:
355
+ pass
356
+
357
+ try:
358
+ # The UI usually computed this while the user was still typing.
359
+ lessons = self.memory.take_prefetched(user_text)
360
+ if lessons is None:
361
+ lessons = self.memory.recall(user_text)
362
+ rules = self.memory.active_rules()
363
+ except Exception:
364
+ return ""
365
+
366
+ self._recalled = lessons
367
+ if lessons:
368
+ self.bus.emit(Kind.MEMORY, action="recalled",
369
+ items=[lesson.as_dict() for lesson in lessons],
370
+ rules=len(rules))
371
+ if not lessons and not rules:
372
+ return ""
373
+ return self.memory.render_playbook(lessons, rules=rules)
374
+
375
+ def _learn(self, user_text: str, result: TurnResult) -> None:
376
+ """Credit the lessons that were in play, then reflect on the episode."""
377
+ if self.memory is None or not self.config.learning.enabled:
378
+ return
379
+ approvals, _ = self.permissions.take_stats()
380
+ try:
381
+ self.memory.record_outcome(
382
+ goal=user_text,
383
+ messages=self.conversation.messages,
384
+ recalled=self._recalled,
385
+ success=result.ok and result.stopped == "done",
386
+ stopped=result.stopped,
387
+ steps=result.steps,
388
+ elapsed=result.elapsed,
389
+ approvals=approvals,
390
+ tokens=self.conversation.usage.total,
391
+ )
392
+ except Exception:
393
+ # Learning is a background nicety; it must never break a turn.
394
+ pass
395
+
396
+
397
+ def make_summariser(gateway: Gateway, model: str) -> Callable[[list[Message]], str]:
398
+ """Standalone summariser, used by session export and tests."""
399
+ from ..providers.base import collapse
400
+
401
+ def summarise(messages: list[Message]) -> str:
402
+ transcript = "\n\n".join(f"[{m.role.value}] {m.content[:2000]}"
403
+ for m in messages if m.content)
404
+ return collapse(gateway.stream(
405
+ [Message.system(COMPACT_PROMPT), Message.user(transcript)],
406
+ model=model, temperature=0.2, max_tokens=1500,
407
+ )).text
408
+
409
+ return summarise