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,428 @@
1
+ """Slash commands.
2
+
3
+ Two kinds. Builtins act on the session (`/cost`, `/compact`, `/plan`) and never
4
+ reach the model. User commands are prompt templates: their body is expanded and
5
+ submitted as the next user turn.
6
+
7
+ Template expansion is the part with teeth. `` !`cmd` `` runs a shell command and
8
+ inlines its output — which means a markdown file in the repository can execute
9
+ code. It therefore goes through the same permission engine as a Bash tool call,
10
+ with no exception. A prompt template is not a trusted input just because it lives
11
+ on disk.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from turnloop.config import PermissionMode, Settings
21
+
22
+ BUILTINS = {
23
+ "help": "list commands",
24
+ "clear": "clear the view (history is kept)",
25
+ "compact": "summarize the conversation now",
26
+ "cost": "show token and cost accounting",
27
+ "context": "show what is filling the context window",
28
+ "model": "show or change the model",
29
+ "provider": "switch provider",
30
+ "permissions": "show permission rules and mode",
31
+ "plan": "switch to plan mode (read-only)",
32
+ "auto": "switch to auto mode (no prompts)",
33
+ "default": "switch back to default permissions",
34
+ "memory": "show discovered memory files",
35
+ "tools": "list available tools",
36
+ "mcp": "show MCP server status",
37
+ "hooks": "show configured hooks",
38
+ "sessions": "list recorded sessions",
39
+ "export": "write the transcript to a markdown file",
40
+ "doctor": "run diagnostics",
41
+ "quit": "exit",
42
+ }
43
+
44
+ ARG_RE = re.compile(r"\$(\d|ARGUMENTS)")
45
+ BANG_RE = re.compile(r"!`([^`]+)`")
46
+ FILE_RE = re.compile(r"@([^\s]+)")
47
+
48
+
49
+ @dataclass
50
+ class CommandOutcome:
51
+ message: str = ""
52
+ prompt: str | None = None # submit this to the model
53
+ echo: bool = False
54
+ clear: bool = False
55
+ quit: bool = False
56
+ mode: PermissionMode | None = None
57
+
58
+
59
+ async def dispatch_command(text: str, agent, settings: Settings, cwd: Path) -> CommandOutcome:
60
+ raw = text.lstrip("/").strip()
61
+ name, _, argument_text = raw.partition(" ")
62
+ name = name.lower()
63
+
64
+ from turnloop.commands.loader import load_commands
65
+
66
+ user_commands = load_commands(settings.project_root)
67
+ if name in user_commands:
68
+ return await _expand_user_command(user_commands[name], argument_text, agent, settings, cwd)
69
+
70
+ handler = _BUILTIN_HANDLERS.get(name)
71
+ if handler is None:
72
+ known = ", ".join(sorted(set(BUILTINS) | set(user_commands)))
73
+ return CommandOutcome(message=f"unknown command /{name}. Available: {known}")
74
+ return await handler(argument_text, agent, settings, cwd)
75
+
76
+
77
+ # --------------------------------------------------------------------------
78
+ # user commands
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ async def _expand_user_command(command, argument_text: str, agent, settings: Settings,
83
+ cwd: Path) -> CommandOutcome:
84
+ args = argument_text.split()
85
+ body = command.body
86
+
87
+ def substitute(match: re.Match[str]) -> str:
88
+ token = match.group(1)
89
+ if token == "ARGUMENTS":
90
+ return argument_text
91
+ index = int(token)
92
+ return args[index - 1] if 0 < index <= len(args) else ""
93
+
94
+ body = ARG_RE.sub(substitute, body)
95
+ body = FILE_RE.sub(lambda m: _inline_file(m.group(1), cwd), body)
96
+ body, refusals = await _inline_commands(body, agent, settings, cwd)
97
+
98
+ message = f"/{command.name}"
99
+ if refusals:
100
+ message += " — " + "; ".join(refusals)
101
+ return CommandOutcome(message=message, prompt=body)
102
+
103
+
104
+ def _inline_file(reference: str, cwd: Path) -> str:
105
+ path = (cwd / reference).expanduser()
106
+ if not path.is_file():
107
+ return f"(missing file: {reference})"
108
+ try:
109
+ content = path.read_text(encoding="utf-8", errors="replace")
110
+ except OSError as exc:
111
+ return f"(unreadable: {reference}: {exc})"
112
+ return f"\n--- {reference} ---\n{content}\n--- end {reference} ---\n"
113
+
114
+
115
+ async def _inline_commands(body: str, agent, settings: Settings, cwd: Path) -> tuple[str, list[str]]:
116
+ """Expand `` !`cmd` `` through the permission engine, not around it."""
117
+ refusals: list[str] = []
118
+ matches = list(BANG_RE.finditer(body))
119
+ if not matches:
120
+ return body, refusals
121
+
122
+ from turnloop.permissions.engine import Verdict
123
+ from turnloop.tools.bash import BashArgs, BashTool
124
+
125
+ tool = BashTool()
126
+ for match in reversed(matches):
127
+ command = match.group(1)
128
+ args = BashArgs(command=command, description="slash command expansion")
129
+ decision = agent.permissions.check(tool, args)
130
+ if decision.verdict is Verdict.DENY:
131
+ replacement = f"(command not run: {decision.reason})"
132
+ refusals.append(f"{command}: denied")
133
+ elif decision.verdict is Verdict.ASK:
134
+ # A template cannot open a modal mid-expansion, and silently running it
135
+ # would let a checked-in markdown file execute whatever it liked.
136
+ replacement = (
137
+ f"(command not run: {command!r} needs approval; add a rule to allow it)"
138
+ )
139
+ refusals.append(f"{command}: needs approval")
140
+ else:
141
+ from turnloop.tools.base import ToolContext
142
+
143
+ ctx = ToolContext(
144
+ cwd=cwd,
145
+ settings=settings,
146
+ session=agent.session,
147
+ permissions=agent.permissions,
148
+ emit=agent.loop.ui.send,
149
+ ask=agent.loop.ui.ask,
150
+ )
151
+ output = await tool.run(args, ctx)
152
+ replacement = output.content
153
+ body = body[: match.start()] + replacement + body[match.end() :]
154
+ return body, refusals
155
+
156
+
157
+ # --------------------------------------------------------------------------
158
+ # builtins
159
+ # --------------------------------------------------------------------------
160
+
161
+
162
+ async def _help(_args, agent, settings, cwd) -> CommandOutcome:
163
+ from turnloop.commands.loader import load_commands
164
+
165
+ lines = ["Commands:"]
166
+ lines += [f" /{name:<12} {desc}" for name, desc in sorted(BUILTINS.items())]
167
+ if user := load_commands(settings.project_root):
168
+ lines.append("Project commands:")
169
+ lines += [
170
+ f" /{c.name:<12} {c.description or c.path.name}" for c in sorted(user.values(), key=lambda c: c.name)
171
+ ]
172
+ lines += [
173
+ "",
174
+ "Keys: ctrl+c interrupt · ctrl+p cycle permission mode · ctrl+l clear · ctrl+d quit",
175
+ ]
176
+ return CommandOutcome(message="\n".join(lines))
177
+
178
+
179
+ async def _clear(_args, agent, settings, cwd) -> CommandOutcome:
180
+ return CommandOutcome(clear=True)
181
+
182
+
183
+ async def _quit(_args, agent, settings, cwd) -> CommandOutcome:
184
+ return CommandOutcome(quit=True)
185
+
186
+
187
+ async def _compact(args, agent, settings, cwd) -> CommandOutcome:
188
+ from turnloop.context.budget import Budget, output_reserve
189
+
190
+ budget = Budget.build(
191
+ agent.provider.caps.max_context,
192
+ output_reserve(
193
+ settings.compaction.reserve_output_tokens, agent.provider.caps.max_output
194
+ ),
195
+ agent.loop.system,
196
+ agent.registry.specs(settings.verbosity_for()),
197
+ )
198
+ compactor = agent.loop.compactor
199
+ if args:
200
+ compactor.extra_instructions = args
201
+ result = await compactor._full_compact(agent.session, budget.available)
202
+ if not result.happened:
203
+ return CommandOutcome(message="nothing to compact yet")
204
+ return CommandOutcome(
205
+ message=(
206
+ f"compacted {result.tokens_before:,} → {result.tokens_after:,} tokens "
207
+ f"({result.reclaimed:,} reclaimed)"
208
+ )
209
+ )
210
+
211
+
212
+ async def _cost(_args, agent, settings, cwd) -> CommandOutcome:
213
+ cost = agent.session.cost
214
+ lines = [
215
+ f"requests {cost.requests}",
216
+ f"input tokens {cost.usage.input_tokens:,}",
217
+ f"output tokens {cost.usage.output_tokens:,}",
218
+ ]
219
+ if cost.usage.cache_read_tokens:
220
+ lines.append(f"cache reads {cost.usage.cache_read_tokens:,}")
221
+ if cost.cost_usd:
222
+ lines.append(f"token cost ${cost.cost_usd:.4f}")
223
+ for name, amount in sorted(cost.by_provider.items()):
224
+ lines.append(f" via {name:<10} ${amount:.4f}")
225
+ if (gpu := agent.provider.gpu_seconds()) is not None:
226
+ hourly = agent.provider.caps.cost_per_hour
227
+ lines.append(f"gpu wall clock {gpu / 60:.1f} min ≈ ${gpu / 3600 * hourly:.2f}")
228
+ lines.append(" stop it with: modal app stop glm-5-2-serve")
229
+ return CommandOutcome(message="\n".join(lines))
230
+
231
+
232
+ async def _context(_args, agent, settings, cwd) -> CommandOutcome:
233
+ """Show what is actually filling the window — usually a surprise."""
234
+ from turnloop.core.tokens import message_tokens, rough_tokens, tool_spec_tokens
235
+
236
+ system = rough_tokens("\n\n".join(agent.loop.system))
237
+ tools = tool_spec_tokens(agent.registry.specs(settings.verbosity_for()))
238
+ history = sum(message_tokens(m) for m in agent.session.messages)
239
+ from turnloop.context.budget import output_reserve
240
+
241
+ reserve = output_reserve(
242
+ settings.compaction.reserve_output_tokens, agent.provider.caps.max_output
243
+ )
244
+ total = agent.provider.caps.max_context
245
+
246
+ biggest = sorted(
247
+ ((message_tokens(m), i, m) for i, m in enumerate(agent.session.messages)),
248
+ reverse=True,
249
+ )[:5]
250
+
251
+ lines = [
252
+ f"window {total:,}",
253
+ f" system {system:,}",
254
+ f" tools {tools:,} ({len(agent.registry)} tools, {settings.verbosity_for()})",
255
+ f" history {history:,} ({len(agent.session.messages)} messages)",
256
+ f" reserved out {reserve:,}",
257
+ f" free {total - system - tools - history - reserve:,}",
258
+ f"compactions {agent.session.compactions}",
259
+ "largest messages:",
260
+ ]
261
+ for tokens, index, message in biggest:
262
+ kind = message.role
263
+ if message.tool_results:
264
+ kind += " (tool result)"
265
+ lines.append(f" #{index:<4} {kind:<20} {tokens:,} tokens")
266
+ return CommandOutcome(message="\n".join(lines))
267
+
268
+
269
+ async def _permissions(_args, agent, settings, cwd) -> CommandOutcome:
270
+ snap = agent.permissions.snapshot()
271
+ lines = [f"mode {snap['mode']}"]
272
+ for key in ("allow", "deny", "ask", "session_grants"):
273
+ if snap[key]:
274
+ lines.append(f"{key:<15} {', '.join(snap[key])}")
275
+ return CommandOutcome(message="\n".join(lines))
276
+
277
+
278
+ async def _plan(_args, agent, settings, cwd) -> CommandOutcome:
279
+ return CommandOutcome(message="plan mode: tools that modify anything are blocked",
280
+ mode="plan")
281
+
282
+
283
+ async def _auto(_args, agent, settings, cwd) -> CommandOutcome:
284
+ return CommandOutcome(message="auto mode: no prompts (deny rules still apply)", mode="auto")
285
+
286
+
287
+ async def _default_mode(_args, agent, settings, cwd) -> CommandOutcome:
288
+ return CommandOutcome(message="default mode: writes ask for confirmation", mode="default")
289
+
290
+
291
+ async def _model(args, agent, settings, cwd) -> CommandOutcome:
292
+ if not args:
293
+ caps = agent.provider.caps
294
+ return CommandOutcome(
295
+ message=(
296
+ f"{agent.provider.name}:{agent.provider.model}\n"
297
+ f"context {caps.max_context:,} · output {caps.max_output:,} · "
298
+ f"caching {'yes' if caps.supports_prompt_caching else 'no'} · "
299
+ f"reasoning field {'yes' if caps.supports_reasoning_field else 'no'}\n"
300
+ "changing the model mid-session needs a restart: "
301
+ "turnloop --provider <name> --model <id>"
302
+ )
303
+ )
304
+ return CommandOutcome(
305
+ message=f"restart with: turnloop --provider {agent.provider.name} --model {args}"
306
+ )
307
+
308
+
309
+ async def _provider(args, agent, settings, cwd) -> CommandOutcome:
310
+ if not args:
311
+ return CommandOutcome(
312
+ message="providers: " + ", ".join(sorted(settings.providers))
313
+ )
314
+ return CommandOutcome(message=f"restart with: turnloop --provider {args}")
315
+
316
+
317
+ async def _memory(_args, agent, settings, cwd) -> CommandOutcome:
318
+ from turnloop.context.memory import discover_memory
319
+
320
+ files = discover_memory(cwd, settings.project_root)
321
+ if not files:
322
+ return CommandOutcome(
323
+ message="no memory files. Create TURNLOOP.md (or CLAUDE.md) for project instructions."
324
+ )
325
+ return CommandOutcome(
326
+ message="\n".join(f"{m.path} ({m.scope}, ~{m.tokens:,} tokens)" for m in files)
327
+ )
328
+
329
+
330
+ async def _tools(_args, agent, settings, cwd) -> CommandOutcome:
331
+ lines = []
332
+ for tool in sorted(agent.registry, key=lambda t: t.name):
333
+ flags = []
334
+ if tool.read_only:
335
+ flags.append("read-only")
336
+ if tool.parallel_safe:
337
+ flags.append("parallel")
338
+ lines.append(f" {tool.name:<18} {', '.join(flags)}")
339
+ return CommandOutcome(message=f"{len(agent.registry)} tools:\n" + "\n".join(lines))
340
+
341
+
342
+ async def _mcp(_args, agent, settings, cwd) -> CommandOutcome:
343
+ if not settings.mcp_servers:
344
+ return CommandOutcome(message="no MCP servers configured")
345
+ lines = []
346
+ for name, cfg in sorted(settings.mcp_servers.items()):
347
+ tools = [t for t in agent.registry.names() if t.startswith(f"mcp__{name}__")]
348
+ state = "disabled" if not cfg.enabled else (f"{len(tools)} tools" if tools else "no tools")
349
+ lines.append(f" {name:<16} {cfg.transport:<6} {state}")
350
+ return CommandOutcome(message="\n".join(lines))
351
+
352
+
353
+ async def _hooks(_args, agent, settings, cwd) -> CommandOutcome:
354
+ if not settings.hooks:
355
+ return CommandOutcome(message="no hooks configured")
356
+ lines = []
357
+ for event, matchers in sorted(settings.hooks.items()):
358
+ for matcher in matchers:
359
+ for hook in matcher.hooks:
360
+ lines.append(f" {event:<18} {matcher.matcher:<12} {hook.command}")
361
+ return CommandOutcome(message="\n".join(lines))
362
+
363
+
364
+ async def _sessions(_args, agent, settings, cwd) -> CommandOutcome:
365
+ from turnloop.sessions.store import SessionStore
366
+
367
+ rows = SessionStore.list_sessions(settings.project_root)[:15]
368
+ if not rows:
369
+ return CommandOutcome(message="no recorded sessions")
370
+ return CommandOutcome(
371
+ message="\n".join(
372
+ f" {r.session_id} {r.started_at:%Y-%m-%d %H:%M} {r.messages:>4} msgs {r.summary[:50]}"
373
+ for r in rows
374
+ )
375
+ )
376
+
377
+
378
+ async def _export(args, agent, settings, cwd) -> CommandOutcome:
379
+ target = Path(args.strip()) if args.strip() else cwd / f"{agent.session.session_id}.md"
380
+ lines = [f"# turnloop session {agent.session.session_id}", ""]
381
+ for message in agent.session.messages:
382
+ if message.role == "user" and message.tool_results:
383
+ for result in message.tool_results:
384
+ status = "error" if result.is_error else "ok"
385
+ lines += [f"**tool result ({status})**", "", "```", result.content[:4000], "```", ""]
386
+ continue
387
+ who = "User" if message.role == "user" else "Assistant"
388
+ lines += [f"## {who}", "", message.text, ""]
389
+ for call in message.tool_uses:
390
+ lines += [f"**{call.name}** `{call.args}`", ""]
391
+ target.write_text("\n".join(lines), encoding="utf-8")
392
+ return CommandOutcome(message=f"exported to {target}")
393
+
394
+
395
+ async def _doctor(_args, agent, settings, cwd) -> CommandOutcome:
396
+ import io
397
+ from contextlib import redirect_stdout
398
+
399
+ from turnloop.diagnostics import run_doctor
400
+
401
+ buffer = io.StringIO()
402
+ with redirect_stdout(buffer):
403
+ run_doctor(settings, cwd)
404
+ return CommandOutcome(message=buffer.getvalue())
405
+
406
+
407
+ _BUILTIN_HANDLERS = {
408
+ "help": _help,
409
+ "clear": _clear,
410
+ "quit": _quit,
411
+ "exit": _quit,
412
+ "compact": _compact,
413
+ "cost": _cost,
414
+ "context": _context,
415
+ "permissions": _permissions,
416
+ "plan": _plan,
417
+ "auto": _auto,
418
+ "default": _default_mode,
419
+ "model": _model,
420
+ "provider": _provider,
421
+ "memory": _memory,
422
+ "tools": _tools,
423
+ "mcp": _mcp,
424
+ "hooks": _hooks,
425
+ "sessions": _sessions,
426
+ "export": _export,
427
+ "doctor": _doctor,
428
+ }
@@ -0,0 +1,140 @@
1
+ """Discovery of user-authored slash commands and skills.
2
+
3
+ Both are markdown files with YAML frontmatter; they differ in how they are
4
+ invoked and, crucially, in what they cost.
5
+
6
+ * A **command** is expanded when the user types `/name`. Its body never sits in
7
+ the system prompt.
8
+ * A **skill** advertises only its `name` and `description` in the system prompt —
9
+ a line or two — and the model loads the full body through the Skill tool when it
10
+ decides the skill applies.
11
+
12
+ That distinction is the whole design. Ten skills with 2,000-token bodies would be
13
+ 20,000 tokens of permanent overhead; as name-and-description pairs they cost about
14
+ 300, and the body arrives only when it is relevant.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class Frontmatter:
27
+ data: dict
28
+ body: str
29
+
30
+
31
+ def parse_frontmatter(text: str) -> Frontmatter:
32
+ """Split a leading `---` YAML block off a markdown file."""
33
+ if not text.startswith("---"):
34
+ return Frontmatter({}, text)
35
+ lines = text.splitlines()
36
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() in ("---", "...")), None)
37
+ if end is None:
38
+ return Frontmatter({}, text)
39
+ try:
40
+ data = yaml.safe_load("\n".join(lines[1:end])) or {}
41
+ except yaml.YAMLError:
42
+ data = {}
43
+ if not isinstance(data, dict):
44
+ data = {}
45
+ return Frontmatter(data, "\n".join(lines[end + 1 :]).lstrip("\n"))
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class UserCommand:
50
+ name: str
51
+ description: str
52
+ body: str
53
+ path: Path
54
+ argument_hint: str = ""
55
+ allowed_tools: list[str] = field(default_factory=list)
56
+ model: str | None = None
57
+
58
+ @property
59
+ def invocation(self) -> str:
60
+ return f"/{self.name}"
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class Skill:
65
+ name: str
66
+ description: str
67
+ body: str
68
+ path: Path
69
+
70
+ def advertise(self) -> str:
71
+ return f"- {self.name}: {self.description}"
72
+
73
+
74
+ def _search_dirs(project_root: Path, kind: str) -> list[tuple[Path, str]]:
75
+ return [
76
+ (Path.home() / ".turnloop" / kind, "user"),
77
+ (project_root / ".turnloop" / kind, "project"),
78
+ ]
79
+
80
+
81
+ def load_commands(project_root: Path) -> dict[str, UserCommand]:
82
+ """Load `.turnloop/commands/**.md`. Project definitions win over user ones.
83
+
84
+ Nested directories namespace the command: `commands/git/sync.md` -> `/git:sync`.
85
+ """
86
+ commands: dict[str, UserCommand] = {}
87
+ for directory, _scope in _search_dirs(project_root, "commands"):
88
+ if not directory.is_dir():
89
+ continue
90
+ for path in sorted(directory.rglob("*.md")):
91
+ rel = path.relative_to(directory).with_suffix("")
92
+ name = ":".join(rel.parts)
93
+ fm = parse_frontmatter(_read(path))
94
+ commands[name] = UserCommand(
95
+ name=name,
96
+ description=str(fm.data.get("description", "")).strip(),
97
+ body=fm.body,
98
+ path=path,
99
+ argument_hint=str(fm.data.get("argument-hint", "")).strip(),
100
+ allowed_tools=_as_list(fm.data.get("allowed-tools")),
101
+ model=fm.data.get("model"),
102
+ )
103
+ return commands
104
+
105
+
106
+ def load_skills(project_root: Path) -> dict[str, Skill]:
107
+ """Load `.turnloop/skills/<name>/SKILL.md`."""
108
+ skills: dict[str, Skill] = {}
109
+ for directory, _scope in _search_dirs(project_root, "skills"):
110
+ if not directory.is_dir():
111
+ continue
112
+ for skill_file in sorted(directory.glob("*/SKILL.md")):
113
+ fm = parse_frontmatter(_read(skill_file))
114
+ name = str(fm.data.get("name") or skill_file.parent.name).strip()
115
+ description = str(fm.data.get("description", "")).strip()
116
+ if not description:
117
+ # Without a description the model has no basis for choosing it, so
118
+ # advertising it would be pure context cost.
119
+ continue
120
+ skills[name] = Skill(
121
+ name=name, description=description, body=fm.body, path=skill_file
122
+ )
123
+ return skills
124
+
125
+
126
+ def _read(path: Path) -> str:
127
+ try:
128
+ return path.read_text(encoding="utf-8", errors="replace")
129
+ except OSError:
130
+ return ""
131
+
132
+
133
+ def _as_list(value) -> list[str]:
134
+ if value is None:
135
+ return []
136
+ if isinstance(value, str):
137
+ return [v.strip() for v in value.split(",") if v.strip()]
138
+ if isinstance(value, list):
139
+ return [str(v).strip() for v in value]
140
+ return []