sliceagent 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 (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/cli.py ADDED
@@ -0,0 +1,890 @@
1
+ """sliceagent CLI — a thin event-sink host over the stateless slice core.
2
+
3
+ The loop only dispatches events; this host wires the sinks (slice-updater, durable
4
+ log, terminal output) and the policy hooks (permission gate, optional Oracle/budget).
5
+ Other surfaces (TUI, SDK, channels) are just different sinks over the same core.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from .events import (
14
+ ApiRetry,
15
+ AssistantText,
16
+ Event,
17
+ LessonSaved,
18
+ SliceBuilt,
19
+ ToolResult,
20
+ TurnEnd,
21
+ TurnInterrupted,
22
+ make_dispatcher,
23
+ )
24
+ from .hooks import BudgetHook, CompositeHooks, GuardrailHook, OracleHook, PermissionHook
25
+
26
+
27
+ def _load_env(path: str = ".env") -> None:
28
+ try:
29
+ with open(path, encoding="utf-8") as f:
30
+ for line in f:
31
+ line = line.strip()
32
+ if line and not line.startswith("#") and "=" in line:
33
+ k, v = line.split("=", 1)
34
+ v = v.strip()
35
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
36
+ v = v[1:-1] # drop surrounding quotes (common .env convention) so the key isn't literal-quoted
37
+ os.environ.setdefault(k.strip(), v)
38
+ except FileNotFoundError:
39
+ pass
40
+
41
+
42
+ LOG_MAX_BYTES = 5 * 1024 * 1024 # rotate the debug log past this (keep one prior)
43
+
44
+
45
+ def log_sink(root: str = ".", path: str | None = None):
46
+ from .recovery import root_key, state_dir
47
+ from .safety import redact_text # strip secrets before they hit the on-disk debug log (off the moat)
48
+ # the debug log lives in the sliceagent STATE dir (~/.sliceagent/logs/<workspace-key>/), NOT scratch/ in the
49
+ # user's workspace — a coding agent must not litter the repo it's working on. `path` overrides (tests).
50
+ path = path or os.path.join(state_dir("logs", root_key(root)), "durable-log.jsonl")
51
+
52
+ def _scrub_args(args: dict) -> dict: # redact string values (edit_file content, inline tokens)
53
+ return {k: (redact_text(v) if isinstance(v, str) else v) for k, v in (args or {}).items()}
54
+
55
+ def sink(e: Event) -> None:
56
+ rec = None
57
+ # REDACT each string field BEFORE serializing (redacting the JSON line itself can corrupt
58
+ # quotes/escapes). A .env read or a token in a command must not land in the log in plaintext
59
+ # — reuses the same safety.redact_text the episodic-persist path uses, so the stores agree.
60
+ if isinstance(e, AssistantText):
61
+ rec = {"role": "assistant", "content": redact_text(e.content)}
62
+ elif isinstance(e, ToolResult):
63
+ rec = {"role": "tool", "name": e.name, "args": _scrub_args(e.args), "full": redact_text(e.output)}
64
+ elif isinstance(e, LessonSaved):
65
+ rec = {"role": "lesson", "title": redact_text(e.title), "content": redact_text(e.content)}
66
+ if rec is not None:
67
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
68
+ try: # rotate so the debug log can't grow unbounded
69
+ if os.path.getsize(path) > LOG_MAX_BYTES:
70
+ os.replace(path, path + ".1")
71
+ except OSError:
72
+ pass
73
+ with open(path, "a", encoding="utf-8") as f:
74
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
75
+ return sink
76
+
77
+
78
+ def _plain_arg(args: dict) -> str:
79
+ """The one informative arg for a plain-mode tool line (path/command/pattern/…), whitespace-collapsed."""
80
+ if not isinstance(args, dict):
81
+ return ""
82
+ for k in ("path", "command", "pattern", "name", "ref", "goal", "task"):
83
+ v = args.get(k)
84
+ if isinstance(v, str) and v.strip():
85
+ return " ".join(v.split())[:60]
86
+ v = next((x for x in args.values() if isinstance(x, str) and x.strip()), "")
87
+ return " ".join(v.split())[:60]
88
+
89
+
90
+ def cli_sink(show_slice: bool = False):
91
+ """Plain stdout sink (AGENT_TUI=off / no tui extra / pipes / CI): no color, no spinner — one readable
92
+ line per tool action (✓/✗ + name + primary arg, output for commands/failures) and a delimited answer."""
93
+ _quiet = {"read_file", "list_files"} # header says enough; their content is noise in a log
94
+
95
+ def sink(e: Event) -> None:
96
+ if isinstance(e, SliceBuilt) and show_slice:
97
+ print("\n ┌─ slice ─────────────")
98
+ print("\n".join(" │ " + ln for ln in e.rendered.splitlines()))
99
+ print(" └─────────────")
100
+ elif isinstance(e, ToolResult):
101
+ mark = "✓" if not e.failing else "✗"
102
+ head = f" {mark} {e.name} {_plain_arg(e.args)}".rstrip()
103
+ out = " ".join((e.output or "").split())[:140]
104
+ if out and (e.failing or e.name not in _quiet):
105
+ head += f" — {out}"
106
+ print(head)
107
+ elif isinstance(e, AssistantText):
108
+ if (e.content or "").strip():
109
+ print(f"\n{e.content}\n")
110
+ elif isinstance(e, ApiRetry):
111
+ print(f" …retry #{e.attempt} ({e.error})")
112
+ elif isinstance(e, TurnInterrupted):
113
+ print(f"\n[interrupted: {e.reason}]")
114
+ elif isinstance(e, LessonSaved):
115
+ print(f" 💡 learned: {e.title}")
116
+ elif isinstance(e, TurnEnd):
117
+ u = e.usage or {} # usage is non-None from loop.py, but guard like every other sink
118
+ print(f" [done: {e.stop_reason} · {e.steps} steps · "
119
+ f"{u.get('prompt_tokens', 0) + u.get('completion_tokens', 0)} tokens]")
120
+ return sink
121
+
122
+
123
+ def _reasoning_note(llm) -> str:
124
+ """One-line hint after a /model or /reasoning switch, so it's never a silent no-op. Checked in order of
125
+ how badly it fails: (1) the model's name implies a DIFFERENT provider than the current endpoint —
126
+ /model only switches the model STRING, never the endpoint (that's `config --use`), so 'gpt-5.5' while
127
+ still connected to DeepSeek used to 'succeed' silently and fail opaquely on the very next real turn (a
128
+ 404 or a 400, always caught by the generic handler as an unhelpful 'internal error'); (2) the requested
129
+ reasoning effort has no route on this (model, endpoint) pair; (3) high/max needs /v1/responses."""
130
+ from .model_catalog import capability, likely_endpoint_mismatch
131
+ base = getattr(llm, "_base_url", "")
132
+ home = likely_endpoint_mismatch(llm.model, base)
133
+ if home:
134
+ label = {"openai": "an OpenAI", "deepseek": "a DeepSeek",
135
+ "moonshot": "a Moonshot", "anthropic": "an Anthropic"}.get(home, f"a {home}")
136
+ return (f"note: {llm.model} looks like {label} model, but you're connected to {base or '(default)'}"
137
+ f" — it will likely fail on your next message. Run `sliceagent init` to add {label} provider, "
138
+ f"then `config --use <id>` to switch (or `/model` with no args to see what's configured).")
139
+ eff = (getattr(llm, "reasoning", "full") or "full").lower()
140
+ if eff == "full":
141
+ return ""
142
+ if not capability(llm.model, base).supports_reasoning_effort:
143
+ return f"note: {llm.model} has no reasoning-effort knob — it runs at the provider default."
144
+ return "high/max run WITH tools via /v1/responses." if eff in ("high", "max") else ""
145
+
146
+
147
+ def _ws_name(path: str) -> str:
148
+ """Short display name for the workspace shown in the status bar — the folder's basename, or '~' when the
149
+ workspace is the home dir (so a home-launched session doesn't read as the username)."""
150
+ p = os.path.realpath(path or ".")
151
+ return "~" if p == os.path.realpath(os.path.expanduser("~")) else (os.path.basename(p) or p)
152
+
153
+
154
+ def main() -> None:
155
+ # subcommands (onboarding / discovery) are handled BEFORE any key gate, so `sliceagent init` runs on a
156
+ # machine with nothing configured yet. A bare `sliceagent` (or one with non-subcommand args) falls through.
157
+ _argv = sys.argv[1:]
158
+ if _argv and _argv[0] in ("init", "config", "help", "--help", "-h", "version", "--version", "-V"):
159
+ from .onboarding import dispatch as _dispatch
160
+ sys.exit(_dispatch(_argv))
161
+
162
+ _load_env()
163
+ # config-persisted key/endpoint (written by `sliceagent init`) populate the env BEFORE the gate, so a
164
+ # configured user never has to export anything; ENV still wins for one-off overrides.
165
+ from .config import load_config
166
+ cfg = load_config()
167
+ for _env, _val in (("LLM_API_KEY", cfg.api_key), ("LLM_BASE_URL", cfg.base_url)):
168
+ if not os.environ.get(_env) and _val:
169
+ os.environ[_env] = _val
170
+ if not (os.environ.get("LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
171
+ or os.environ.get("MOONSHOT_API_KEY")):
172
+ print("No API key found. Run `sliceagent init` for guided setup, or set LLM_API_KEY (e.g. in a .env file).")
173
+ sys.exit(1)
174
+ # validate enum env vars (warn + use default; never crash) — a typo'd AGENT_POLICY is now visible.
175
+ from .envspec import validate_env
176
+ for _w in validate_env():
177
+ print(f" · config warning: {_w}")
178
+
179
+ from .code_index import make_code_index
180
+ from .hippocampus import make_episode_sink
181
+ from .llm import OpenAILLM
182
+ from .mcp_client import connect_mcp_servers
183
+ from .loop import run_turn
184
+ from .memory import make_memory
185
+ from .oracle import CommandOracle
186
+ from .plugins import load_plugins
187
+ from .policy import CONFIRMS, legacy_warning, make_policy, policy_label, resolve_policy_mode
188
+ from .sandbox import make_sandbox
189
+ from .session import Session, make_topic_tools, route
190
+ from .skills import make_skill_manager, make_skill_tool
191
+ from .pfc import consolidate_checkpoint, record_user, slice_sink
192
+ from .seed import make_build_slice
193
+ from .text_utils import one_line
194
+ from .subagent import SubagentHost
195
+ from .tools import LocalToolHost
196
+
197
+ # cfg already loaded above (for the config→env key population + the gate)
198
+ root = os.getcwd()
199
+ from .config import load_prefs, save_prefs
200
+ _prefs = load_prefs()
201
+ # mode resolution: explicit env wins, then the saved /mode choice, then config (default teenager).
202
+ _raw_policy = os.environ.get("AGENT_POLICY") or _prefs.get("policy") or cfg.policy
203
+ canonical = resolve_policy_mode(_raw_policy) or "teenager"
204
+ _pol_warn = legacy_warning(_raw_policy) # loud note if a legacy name (e.g. guard) was used — no silent downgrade
205
+ if _pol_warn:
206
+ print(f" · {_pol_warn}")
207
+ mine_mode = cfg.mine # deterministic | llm | off
208
+ sub_depth = cfg.subagent_depth # 0 disables delegation
209
+ # SUBAGENT/base policy never prompts (no human in a spawned turn) → a confirm-mode runs as let-it-go for
210
+ # them (still blocks catastrophic). The MAIN agent's confirming policy is built at the hook below.
211
+ policy = make_policy("letitgo" if CONFIRMS.get(canonical) else canonical)
212
+ # model + reasoning resolution: explicit env wins, then the saved /model choice (prefs), then config.
213
+ _model = os.environ.get("AGENT_MODEL") or _prefs.get("model") or cfg.model
214
+ if not _model: # no built-in default — the user picks the model (parallels the API-key gate above)
215
+ print("No model configured. Run `sliceagent init` to pick a provider + model, "
216
+ "or set AGENT_MODEL to your model name.")
217
+ sys.exit(1)
218
+ llm = OpenAILLM(model=_model)
219
+ if _prefs.get("reasoning") and not (os.environ.get("AGENT_REASONING") or os.environ.get("AGENT_THINKING")):
220
+ llm.reasoning = str(_prefs["reasoning"]).lower() # apply the saved /reasoning choice
221
+ retriever = make_code_index(root) # ripgrep CodeIndex (RELATED CODE tier); NullRetriever if no rg
222
+ memory = make_memory() # memem if available + a vault is configured, else NullMemory
223
+
224
+ sandbox = make_sandbox(cfg.sandbox_backend, image=cfg.sandbox_image, network=cfg.sandbox_network)
225
+ base_tools = LocalToolHost(root, sandbox=sandbox) # file ops confined to launch dir; shell via sandbox
226
+ for _r in os.environ.get("AGENT_ROOT", "").split(os.pathsep): # I2: extra dirs the user puts in reach
227
+ if _r.strip():
228
+ base_tools.add_root(_r.strip())
229
+ skills = make_skill_manager(cfg.skills_roots) # SKILL.md packs (config dirs or defaults)
230
+ # plugins: feed the SAME registry/skills, and contribute MCP servers + hooks (loaded first
231
+ # so plugin skills enter the catalog and plugin MCP servers get connected below)
232
+ plugin_mcp, plugin_hooks = load_plugins(
233
+ base_tools.registry, skills, cfg.plugin_dirs, root=root, config=cfg,
234
+ on_log=lambda m: print(f" · {m}"))
235
+ skill_tool = make_skill_tool(skills)
236
+ if skill_tool is not None: # register the `skill` tool into the shared registry
237
+ base_tools.registry.register(skill_tool)
238
+ from .code_grep import make_glob_tool, make_grep_tool # ripgrep discovery: grep (contents) + glob (names)
239
+ base_tools.registry.register(make_grep_tool(base_tools))
240
+ base_tools.registry.register(make_glob_tool(base_tools))
241
+
242
+ # WORKSPACE SWITCH — the agent CAN re-root when the user explicitly asks (the same re-root /cwd performs).
243
+ # Shared by the change_workspace tool AND /cwd. User-authorized either way; teenager mode still confirms
244
+ # the tool call, so switching is one tap instead of typing the whole path.
245
+ def _reroot(path: str) -> str:
246
+ nonlocal root, retriever
247
+ raw = os.path.expanduser((path or "").strip())
248
+ # a RELATIVE path (the agent often passes 'hunter') resolves against the CURRENT workspace, not the
249
+ # process cwd — so "switch to hunter" means <workspace>/hunter, as the user intends.
250
+ newp = os.path.realpath(raw if os.path.isabs(raw) else os.path.join(base_tools.root(), raw))
251
+ if not os.path.isdir(newp):
252
+ return f"not a directory: {path}"
253
+ base_tools._root = newp
254
+ base_tools._focus = None
255
+ base_tools._extra_roots.clear()
256
+ base_tools._subdir_hints = None # reset the convention-hint tracker (SENSORY CORTEX —
257
+ # derived from the filesystem, not persisted)
258
+ root = newp # crash-recovery WAL + @mentions track the new root
259
+ try:
260
+ _stats["workspace"] = _ws_name(newp) # status bar reflects the new workspace immediately
261
+ except NameError: # _stats not built yet (reroot before the REPL loop) — ignore
262
+ pass
263
+ try:
264
+ retriever = make_code_index(newp) # re-index for the RELATED CODE tier
265
+ except Exception: # noqa: BLE001 — re-index is best-effort; the re-root still stands
266
+ pass
267
+ return f"workspace switched → {newp} (repo map, file tools & commands now rooted here)"
268
+
269
+ from .registry import ToolEntry
270
+ _CW_SCHEMA = {"type": "function", "function": {
271
+ "name": "change_workspace",
272
+ "description": ("Switch your workspace ROOT to another directory — call this when the user explicitly "
273
+ "asks to switch workspace / open a project / work in a different folder (e.g. 'switch "
274
+ "to hunter', 'work in ~/proj'). Re-roots your file tools, run_command cwd, repo map and "
275
+ "git to that dir. Do NOT call it for a mere mention of a path."),
276
+ "parameters": {"type": "object", "properties": {
277
+ "path": {"type": "string", "description": "Directory to switch to (absolute or ~)."}},
278
+ "required": ["path"]}}}
279
+ base_tools.registry.register(ToolEntry(
280
+ name="change_workspace", schema=_CW_SCHEMA,
281
+ handler=lambda args: _reroot(str((args or {}).get("path") or "")),
282
+ accesses=lambda args: [], source="builtin"))
283
+ # web tools (fetch_url + web_search, DuckDuckGo, no key) — network egress, so gated by AGENT_WEB
284
+ # (default ON). SSRF-guarded + results fenced UNTRUSTED + large pages paged (see web.py).
285
+ if os.environ.get("AGENT_WEB", "1").strip().lower() not in ("0", "off", "false", "no"):
286
+ from .web import make_web_tools
287
+ for _wt in make_web_tools(base_tools):
288
+ base_tools.registry.register(_wt)
289
+ # foreground SKILL writer — the agent-callable tool /learn drives to turn a transcript into a
290
+ # reusable USER-provenance skill (guarded write: validate + threat-scan + redact + atomic).
291
+ from .memory import make_write_skill_tool
292
+ base_tools.registry.register(make_write_skill_tool())
293
+ # MCP: connect config + plugin-declared servers; tools register into the SAME registry
294
+ mcp_servers, mcp_runtime = connect_mcp_servers(
295
+ base_tools.registry, {**cfg.mcp_servers, **plugin_mcp}, on_log=lambda m: print(f" · {m}"),
296
+ page_out=base_tools._page_out) # big MCP results → blob + head/tail view, not inlined whole
297
+ mcp_tool_count = sum(1 for e in base_tools.registry._tools.values() if e.source == "mcp")
298
+ plugin_tool_count = sum(1 for e in base_tools.registry._tools.values()
299
+ if e.source.startswith("plugin:"))
300
+ # subagent activity → ONE dynamic line, not a line per child tool call. Late-bound: the
301
+ # renderer is set once the rich sink exists (below); plain/headless leaves it None (the spawn tool's
302
+ # result line carries the child's summary, so nothing is lost).
303
+ _sub_render: dict = {"fn": None}
304
+ def _notify_subagent(text):
305
+ fn = _sub_render["fn"]
306
+ if fn is not None:
307
+ fn(text)
308
+ tools = base_tools
309
+ if sub_depth > 0: # wrap so the model can delegate sub-tasks (summary-only return)
310
+ from .agents import load_agents
311
+ # named-agent registry: built-ins (explorer, general) + user-defined <root>/agents/*.md
312
+ agent_roots = list(cfg.skills_roots or []) + [root, os.path.join(root, ".sliceagent")]
313
+ tools = SubagentHost(base_tools, llm=llm, retriever=retriever, memory=memory,
314
+ policy=policy, max_depth=sub_depth, notify=_notify_subagent,
315
+ agents=load_agents(agent_roots))
316
+ session = Session(memory) # host-side topic manager (one bounded Slice per topic)
317
+ llm.set_cache_key(session.session_id) # session-stable prompt-cache routing (cheapest cache lever)
318
+ for t in make_topic_tools(session): # model can route topics via new_topic / switch_topic
319
+ base_tools.registry.register(t)
320
+ # recall_history: the model's bounded valve into the cold cache (paged-out turns of this session).
321
+ if getattr(memory, "is_durable", False):
322
+ from .hippocampus import make_history_tool
323
+ base_tools.registry.register(make_history_tool(memory, session.session_id))
324
+
325
+ # write side of the memory loop is CACHE-ONLY: distillation runs at session end in
326
+ # memory.consolidate (reads the episodic cache, never the slice). `mine_mode` (off|deterministic|llm)
327
+ # gates that consolidation — see the session-end call below. No per-turn slice-coupled miner.
328
+ # OPT-IN async background-review fork (item 16; OFF unless AGENT_BACKGROUND_REVIEW set).
329
+ # Reads the durable episodic cache off-thread and consolidates incrementally — never
330
+ # touches the slice/loop/prompt. None when disabled, so the default path is unchanged.
331
+ from .background_review import make_background_reviewer
332
+ reviewer = make_background_reviewer(memory, scope=os.path.basename(root) or "default",
333
+ on_log=lambda m: print(f" · {m}"))
334
+ # episodic cache: lossless turn log (None for NullMemory → eval path untouched)
335
+ episodic = make_episode_sink(memory, session_id=session.session_id,
336
+ task_id_fn=lambda: session.active_id or "t-none",
337
+ title_fn=lambda: one_line(session.active().goal, 80) if session.active_id else "",
338
+ # task-outcome signal for consolidation: how many STANDING REQUIREMENTS
339
+ # were still open at turn end (0 = none declared OR all met). promote_procedures
340
+ # won't mine a "successful workflow" skill from a task that left some unmet.
341
+ outcome_fn=lambda: {"requirements_open": sum(
342
+ 1 for r in session.active().requirements
343
+ if isinstance(r, dict) and not r.get("done"))} if session.active_id else {})
344
+
345
+ # optional rich TUI (the `tui` extra). Output via Rich, input via prompt_toolkit — temporally
346
+ # separate from the synchronous run_turn, so no patch_stdout/threading. Off when piped (eval).
347
+ _tui = None
348
+ _stats = {"model": llm.model, "policy": policy_label(canonical), "topic": "",
349
+ "workspace": _ws_name(root), "tokens": 0}
350
+ try:
351
+ from . import tui as _tuimod
352
+ if _tuimod.tui_enabled():
353
+ _tui = _tuimod
354
+ except Exception:
355
+ _tui = None
356
+ _console = _tui.make_console() if _tui else None # themed: no black-bg highlight on inline `code`/paths
357
+
358
+ # DEFAULT UI = the inline rich+prompt_toolkit REPL: it stays in the NORMAL terminal buffer, so native
359
+ # copy / paste / scrollback work on ANY terminal (incl. macOS Terminal.app), with a pinned composer
360
+ # (patch_stdout, which provides a pinned static region above a live-updating composer) and
361
+ # streaming replies. AGENT_TUI=off → plain stdout (handled in tui_enabled). AGENT_TUI=live → the
362
+ # always-pinned live composer: the bordered box stays at the bottom EVEN WHILE the agent streams (output
363
+ # prints above it). Opt-in/experimental; the default REPL (box between turns) is the proven path, and
364
+ # live falls back to it if it can't start.
365
+ tui_env = os.environ.get("AGENT_TUI", "").strip().lower()
366
+ use_live = (_tui is not None and tui_env == "live")
367
+
368
+ # wire the ask_user capability to a real prompt when interactive — NOT in live mode, where a worker-
369
+ # thread console.input() would contend with the pinned prompt_toolkit app for stdin and HANG.
370
+ if not use_live and (_tui or sys.stdin.isatty()):
371
+ # wire the ask_user capability to a real prompt when interactive (TUI rich prompt, or plain
372
+ # input); headless/eval — AND live mode, where a worker-thread console.input() would contend with
373
+ # the pinned prompt_toolkit app for stdin and HANG — keep the non-interactive default.
374
+ def _ask_user(question, options):
375
+ if _tui:
376
+ return _tui.ask_user(_console, question, options)
377
+ print(f"\n ❓ {question}")
378
+ for i, o in enumerate(options or [], 1):
379
+ print(f" {i}. {o}")
380
+ try:
381
+ a = input(" your answer ▸ ").strip()
382
+ except (EOFError, KeyboardInterrupt):
383
+ return "(no answer)"
384
+ if options and a.isdigit() and 1 <= int(a) <= len(options):
385
+ return options[int(a) - 1]
386
+ return a or "(no answer)"
387
+ base_tools.on_ask_user = _ask_user
388
+
389
+ # sinks: update the active slice from tool results, cache the turn, persist, print (no per-turn
390
+ # miner — distillation is cache-only at session end via memory.consolidate).
391
+ sinks = [slice_sink(session)]
392
+ if episodic is not None:
393
+ sinks.append(episodic)
394
+ sinks.append(log_sink(root))
395
+ # optional: the moat-MEASURING cost sink (AGENT_METRICS=1). Accumulates the per-turn FRESH-input
396
+ # curve (should stay flat as the conversation grows) + cache-hit rate + reliability counters; the
397
+ # summary prints at session end. Pure observer — eval/default path untouched.
398
+ metrics = None
399
+ if os.environ.get("AGENT_METRICS"):
400
+ from .metrics import make_metrics_sink
401
+ metrics = make_metrics_sink()
402
+ sinks.append(metrics)
403
+ # EXACTLY ONE renderer is wired: the rich+prompt_toolkit sink (TUI), OR the plain stdout sink
404
+ # (headless/eval). Never two.
405
+ if _tui:
406
+ _rich = _tui.make_rich_sink(_console, _stats)
407
+ sinks.append(_rich)
408
+ llm.set_delta_sink(_rich.on_delta) # STREAM completions live into the rich TUI spinner
409
+ # child agent activity → one dynamic spinner line. NOT in live mode: a rich console Status would
410
+ # fight the pinned prompt_toolkit Application for the screen (garbled output) — let the spawn tool's
411
+ # result line carry the child summary instead, as the plain/headless path does.
412
+ _sub_render["fn"] = None if use_live else _rich.subagent_notify
413
+ else:
414
+ sinks.append(cli_sink(cfg.show_slice))
415
+ # optional: feed the live web monitor (AGENT_MONITOR=1) — eval path untouched. Writes per-step
416
+ # snapshots to the shared monitor dir; view them in the STANDING server (python -m sliceagent.monitor),
417
+ # which stays up across sessions and goes idle when none is running.
418
+ monitor_sink = None
419
+ if os.environ.get("AGENT_MONITOR"):
420
+ from .monitor import _monitor_dir, make_file_monitor_sink
421
+ monitor_sink = make_file_monitor_sink(
422
+ session.session_id,
423
+ context_fn=lambda: {"goal": session.active().goal if session.active_id else "",
424
+ "topic": session.active_id or ""})
425
+ sinks.append(monitor_sink)
426
+ print(f" · slice monitor: writing to {_monitor_dir()} — view at the persistent server "
427
+ "(run: python -m sliceagent.monitor)")
428
+ dispatch = make_dispatcher(*sinks)
429
+
430
+ # policy hooks (the seam is always wired; default 'guard' blocks catastrophic commands)
431
+ def _ask(name, args, reason): # interactive resolver for AGENT_POLICY=ask
432
+ detail = args.get("command") or args.get("path") or args.get("code", "")
433
+ if use_live: # the pinned prompt_toolkit app owns stdin → a Rich confirm would hang; deny (safe default)
434
+ return "no"
435
+ if _tui: # synchronous mid-run (no pt app live) → a Rich confirm is safe
436
+ return _tui.confirm(_console, name, str(detail), reason)
437
+ if not sys.stdin.isatty():
438
+ return "no"
439
+ ans = input(f" ⚠ allow {name} {str(detail)[:60]!r}? ({reason}) [y]es/[n]o/[a]lways: ").strip().lower()
440
+ return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
441
+
442
+ def _handle_slash(line): # TUI navigation palette — wired to existing session ops
443
+ parts = line.split(maxsplit=1)
444
+ cmd, arg = parts[0], (parts[1].strip() if len(parts) > 1 else "")
445
+ if cmd == "/help":
446
+ _console.print("commands: /model · /mode · /cwd · /learn · /plan · /cost · /threads · /plugins · /mcp · "
447
+ "/help · /exit\n (type / for the menu · /cwd <path> switches workspace · "
448
+ "Esc = undo last turn · say \"review my changes\" for code_review · @path pins a file)")
449
+ elif cmd == "/plan":
450
+ s = session.active() if session.active_id else None
451
+ plan = getattr(s, "plan", None) if s else None
452
+ mission = getattr(s, "mission", "") if s else ""
453
+ if mission:
454
+ _console.print(f" 🎯 mission: {mission}", markup=False)
455
+ if not plan:
456
+ _console.print(" (no active plan — the agent sets one with update_plan on multi-step tasks)")
457
+ else:
458
+ mark = {"done": "✓", "in_progress": "▶", "pending": "○"}
459
+ for it in plan:
460
+ _console.print(f" {mark.get(it.get('status'), '○')} {it.get('step', '')}", markup=False)
461
+ elif cmd == "/cost":
462
+ from .tui import _saved_dollars
463
+ saved = _saved_dollars(_stats)
464
+ spent = _stats.get("cost", 0.0)
465
+ head = (f" 💰 saved ${saved:.4f} vs full-history (@ {_stats.get('model','?')}, cache-aware)"
466
+ f" · spent ${spent:.4f}" if saved is not None
467
+ else f" 💰 {_stats.get('saved_cached_tok', 0):,} tokens saved vs full-history (model price unknown)")
468
+ _console.print(head)
469
+ if metrics is None:
470
+ _console.print(" (per-turn curve off — start with AGENT_METRICS=1 to track it)")
471
+ else:
472
+ s = metrics.summary()
473
+ _console.print(f" per_turn_fresh={s['per_turn_fresh']} avg={s['avg_turn_fresh']} "
474
+ f"cache_hit={s['cache_hit_rate']} tools={s['tool_calls']} "
475
+ f"out={s['output']} retries={s['retries']} overflows={s['overflows']}")
476
+ elif cmd == "/threads":
477
+ ts = session.open_threads(include_active=True)
478
+ # markup=False: task_id/title are DATA — `[{t.task_id}]` renders as a Rich tag → MarkupError crash.
479
+ _console.print((" (no topics yet)" if not ts else
480
+ "\n".join(f" [{t.task_id}] {t.title} ({t.status})" for t in ts)), markup=False)
481
+ elif cmd in ("/switch", "/resume"):
482
+ if not arg:
483
+ _console.print(f" usage: {cmd} <task_id>")
484
+ else:
485
+ try:
486
+ session.switch_topic(arg)
487
+ _stats["topic"] = one_line(session.active().goal, 40)
488
+ _console.print(f" switched to {arg}", markup=False)
489
+ except Exception:
490
+ _console.print(f" no such topic: {arg}", markup=False)
491
+ elif cmd == "/undo":
492
+ _console.print(" " + base_tools.undo_last()) # revert the last file edit
493
+ elif cmd == "/plugins":
494
+ tools = sorted(e.name for e in base_tools.registry._tools.values()
495
+ if getattr(e, "source", "") == "plugin")
496
+ _console.print(f" plugin dirs: {', '.join(cfg.plugin_dirs) or '(none configured)'}")
497
+ _console.print(f" plugin tools ({len(tools)}): {', '.join(tools) or '(none loaded)'}")
498
+ elif cmd == "/mcp":
499
+ configured = list(cfg.mcp_servers.keys())
500
+ mtools = sorted(e.name for e in base_tools.registry._tools.values()
501
+ if getattr(e, "source", "") == "mcp")
502
+ if not configured and not mtools:
503
+ _console.print(" no MCP servers configured — add [mcp_servers.<name>] to ~/.sliceagent/config.toml")
504
+ else:
505
+ _console.print(f" configured servers: {', '.join(configured) or '(none)'}")
506
+ _console.print(f" connected tools ({len(mtools)}): {', '.join(mtools) or '(none — check startup logs)'}")
507
+ elif cmd == "/mode":
508
+ _menu_ok = sys.stdin.isatty() and not use_live
509
+ chosen = None
510
+ if not arg and _menu_ok: # no arg + interactive → open the picker menu
511
+ from .tui import run_selector
512
+ order = ["babysitter", "teenager", "letitgo"]
513
+ rows = [("baby-sitter", "confirm every edit + command"),
514
+ ("teenager", "auto edits, confirm commands"),
515
+ ("let-it-go", "auto-run — still blocks catastrophic moves")]
516
+ cur = next((i for i, k in enumerate(order) if policy_label(k) == _stats.get("policy")), -1)
517
+ pick = run_selector("Permission mode", rows, current=cur)
518
+ chosen = order[pick] if pick is not None else None
519
+ elif arg: # typed: /mode teenager
520
+ chosen = resolve_policy_mode(arg)
521
+ if chosen not in ("babysitter", "teenager", "letitgo"):
522
+ _console.print(" unknown mode — use: baby-sitter | teenager | let-it-go"); return True
523
+ else: # no arg, non-interactive → just show current
524
+ _console.print(f" mode: [bold]{_stats.get('policy', '?')}[/] options: baby-sitter · teenager · let-it-go")
525
+ _console.print(" baby-sitter = confirm every edit + command · teenager = auto edits, confirm "
526
+ "commands · let-it-go = auto (blocks catastrophic)")
527
+ if chosen:
528
+ eff = chosen if (sys.stdin.isatty() and not use_live) or not CONFIRMS.get(chosen) else "letitgo"
529
+ perm_hook.policy = make_policy(eff)
530
+ perm_hook.on_ask = _ask if CONFIRMS.get(eff) else None
531
+ _stats["policy"] = policy_label(eff)
532
+ save_prefs({"policy": eff}) # remember the choice for next launch
533
+ _console.print(f" → [bold]{policy_label(eff)}[/]"
534
+ + ("" if eff == chosen else " (no interactive prompt here → running as let-it-go)"))
535
+ elif cmd == "/model":
536
+ if not arg and sys.stdin.isatty() and not use_live: # open the two-tier model→reasoning menu
537
+ from .tui import select_model_reasoning
538
+ choice = select_model_reasoning(llm, cfg)
539
+ if choice:
540
+ llm.switch(model=choice[0], reasoning=choice[1])
541
+ _stats["model"] = llm.model
542
+ save_prefs({"model": llm.model, "reasoning": llm.reasoning})
543
+ note = _reasoning_note(llm)
544
+ _console.print(f" ✓ model → [bold]{llm.model}[/] · reasoning [bold]{llm.reasoning}[/] (saved)"
545
+ + (f"\n {note}" if note else ""))
546
+ elif not arg:
547
+ _console.print(f" model: [bold]{llm.model}[/] · reasoning: [bold]{llm.reasoning}[/]"
548
+ f" · net: {getattr(llm, 'proxy_used', 'direct')}")
549
+ known = ("gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "deepseek-chat",
550
+ "kimi-k2-0905-preview", "claude-sonnet-4-6")
551
+ _console.print(" switch: /model <name> [fast|full|high|max]")
552
+ _console.print(" known: " + ", ".join(known))
553
+ provs = cfg.providers()
554
+ if provs:
555
+ _console.print(" providers (use `config --use <id>` to change endpoint): "
556
+ + ", ".join(f"{k}={v.get('model', '?')}" for k, v in provs.items()))
557
+ else:
558
+ name, *rest = arg.split()
559
+ eff = rest[0].lower() if rest else None
560
+ if eff and eff not in ("fast", "full", "high", "max"):
561
+ _console.print(" effort must be one of: fast | full | high | max"); return True
562
+ llm.switch(model=name, reasoning=eff)
563
+ _stats["model"] = llm.model
564
+ save_prefs({"model": llm.model, "reasoning": llm.reasoning})
565
+ note = _reasoning_note(llm)
566
+ _console.print(f" ✓ model → [bold]{llm.model}[/]"
567
+ + (f" · reasoning [bold]{llm.reasoning}[/]" if eff else "")
568
+ + " (saved)" + (f"\n {note}" if note else ""))
569
+ elif cmd == "/reasoning":
570
+ if arg.lower() not in ("fast", "full", "high", "max"):
571
+ _console.print(" usage: /reasoning <fast|full|high|max>"
572
+ " (full = provider default; high/max use /v1/responses for gpt-5)")
573
+ else:
574
+ llm.switch(reasoning=arg)
575
+ save_prefs({"reasoning": llm.reasoning})
576
+ note = _reasoning_note(llm)
577
+ _console.print(f" ✓ reasoning → [bold]{llm.reasoning}[/] (saved)" + (f"\n {note}" if note else ""))
578
+ elif cmd == "/cwd":
579
+ # Manual workspace switch (the agent also does this via change_workspace when you ask). Shares
580
+ # _reroot: re-roots file tools + run_command cwd + repo map + git; re-indexes; crash-recovery
581
+ # follows the new root. (A running subagent host keeps the old code index until relaunch.)
582
+ if not arg:
583
+ _console.print(f" workspace: {base_tools.root()}", markup=False)
584
+ else:
585
+ _console.print(" ✓ " + _reroot(arg))
586
+ else:
587
+ _console.print(f" unknown command {cmd} (/help)", markup=False)
588
+ return True
589
+
590
+ _IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
591
+
592
+ def _expand_mentions(text):
593
+ """@path mentions: pin each EXISTING workspace file referenced as @path into
594
+ the slice's OPEN FILES; an @image is ATTACHED as a vision content part for the next turn when the model
595
+ supports vision (else skipped with a hint). Best-effort; leaves the text intact."""
596
+ if "@" not in text or session.active_id is None:
597
+ return
598
+ import re as _re
599
+ from .model_catalog import capability
600
+ from .pfc import touch_file
601
+ vision = capability(llm.model, getattr(llm, "_base_url", "")).supports_vision
602
+ pinned, images, skipped = [], [], []
603
+ for m in _re.findall(r"@([\w./\-]+)", text):
604
+ rel = m.lstrip("/")
605
+ if ".." in rel: # never let @../ reach outside the workspace (defense-in-depth)
606
+ continue
607
+ if not (rel and os.path.isfile(os.path.join(root, rel))):
608
+ continue
609
+ if rel.lower().endswith(_IMG_EXT):
610
+ if vision:
611
+ res = base_tools.attach_image(rel) # only claim "attached" if it actually worked
612
+ (skipped if isinstance(res, str) and res.startswith("Error") else images).append(rel)
613
+ else:
614
+ skipped.append(rel)
615
+ else:
616
+ touch_file(session.active(), rel); pinned.append(rel)
617
+ if _tui and _console is not None:
618
+ if pinned: # paths like app/jobs/[id]/page.tsx contain [ ] → markup=False or Rich crashes
619
+ _console.print(f" 📎 pinned: {', '.join(pinned)}", markup=False)
620
+ if images:
621
+ _console.print(f" 🖼 attached image: {', '.join(images)}", markup=False)
622
+ if skipped:
623
+ _console.print(f" 🖼 skipped (needs a vision-capable AGENT_MODEL): {', '.join(skipped)}", markup=False)
624
+
625
+ # AGENT_AUTO_APPROVE: comma-separated fnmatch globs over the command, pre-approved so safe read-only
626
+ # commands never prompt (e.g. AGENT_AUTO_APPROVE="git status*,git diff*,ls *,cat *").
627
+ _auto = [r.strip() for r in (os.environ.get("AGENT_AUTO_APPROVE") or "").split(",") if r.strip()]
628
+ # MAIN agent's policy: the canonical mode, but a confirm-mode needs a human — with no interactive prompt
629
+ # (headless/piped, or the live composer that owns stdin) fall back to let-it-go (auto, still catastrophic-gated).
630
+ _can_confirm = sys.stdin.isatty() and not use_live
631
+ _eff_mode = canonical if (_can_confirm or not CONFIRMS.get(canonical)) else "letitgo"
632
+ _stats["policy"] = policy_label(_eff_mode)
633
+ if CONFIRMS.get(canonical) and _eff_mode != canonical: # confirm-mode with no way to prompt → say so
634
+ print(f" · non-interactive shell: '{policy_label(canonical)}' can't ask for confirmation here → "
635
+ f"running as '{policy_label(_eff_mode)}' (auto-run, still blocks catastrophic commands).")
636
+ perm_hook = PermissionHook(make_policy(_eff_mode),
637
+ on_ask=_ask if CONFIRMS.get(_eff_mode) else None, auto_approve=_auto)
638
+ hook_list = [perm_hook]
639
+ hook_list.append(GuardrailHook()) # cross-step loop guard (per-turn counters, reset each task)
640
+ if cfg.verify_cmd:
641
+ oracle = CommandOracle(cfg.verify_cmd)
642
+ hook_list.append(OracleHook(oracle, lambda out: setattr(session.active(), "last_error", f"Verification failed:\n{out[:600]}")))
643
+ if cfg.max_tokens:
644
+ hook_list.append(BudgetHook(cfg.max_tokens))
645
+ hook_list.extend(plugin_hooks) # plugins compose into the same hook chain
646
+ hooks = CompositeHooks(*hook_list)
647
+
648
+ info = (f"model={llm.model} · net={getattr(llm, 'proxy_used', 'direct')} · "
649
+ f"policy={policy_label(_eff_mode)} · sandbox={cfg.sandbox_backend} · "
650
+ f"code={type(retriever).__name__} · memory={type(memory).__name__} · "
651
+ f"episodic={'on' if episodic is not None else 'off'} · "
652
+ f"mine={mine_mode} · subagents={'on' if sub_depth > 0 else 'off'} · "
653
+ f"skills={len(skills.names())} · mcp_tools={mcp_tool_count} · plugin_tools={plugin_tool_count}")
654
+ # ── choose UI: the always-pinned live composer (AGENT_TUI=live), else the rich+prompt_toolkit REPL ──
655
+ _input = _tui.TuiInput(_stats, root=root) if _tui else None
656
+
657
+ from .text_utils import is_chitchat
658
+
659
+ def _chitchat_reply(text, _dispatch):
660
+ """A pure greeting/social message → cheap reply: MINIMAL prompt, NO tools, NO slice. Skips the full
661
+ per-turn token cost (the 12k system prompt + tool schemas + tiers) for a 'hi'/'thanks'. (item D)"""
662
+ from .text_utils import CHITCHAT_PROMPT
663
+ msgs = [{"role": "system", "content": CHITCHAT_PROMPT}, {"role": "user", "content": text}]
664
+ try:
665
+ am = llm.complete(msgs, []) # NO tools
666
+ _dispatch(AssistantText((am.content or "").strip() or "Hi! What would you like to work on?"))
667
+ except Exception: # noqa: BLE001 — a chitchat reply must never crash the session
668
+ _dispatch(AssistantText("Hi! What would you like to work on?"))
669
+
670
+ def _run_one_turn(text, sink, signal):
671
+ """One turn for the LIVE composer: route (lexical) → build slice → run_turn with a per-turn dispatch
672
+ that feeds the LiveSink. Runs in run_live's worker thread, so the pinned box stays responsive."""
673
+ if is_chitchat(text): # 'hi'/'thanks' → cheap reply, no slice/tools (item D)
674
+ _chitchat_reply(text, make_dispatcher(sink))
675
+ return
676
+ if session.active_id is None:
677
+ session.new_topic(text)
678
+ else:
679
+ action, tid = route(llm, text, session)
680
+ if action == "new":
681
+ session.new_topic(text)
682
+ elif action == "resume":
683
+ session.switch_topic(tid); session.continue_topic(text, resume=True)
684
+ else:
685
+ session.continue_topic(text)
686
+ _stats["topic"] = one_line(session.active().goal, 40) if session.active_id else ""
687
+ record_user(session.active(), text)
688
+ _expand_mentions(text) # @path → pin the file into OPEN FILES
689
+ build = make_build_slice(session, tools, retriever, memory, text, session.session_id)
690
+ # live mode must wire the SAME host sinks as the REPL path (episodic cache, durable log, metrics) —
691
+ # not just slice+renderer — else the cache→memory loop and /cost produce NOTHING in live mode.
692
+ _live_sinks = [slice_sink(session)]
693
+ if episodic is not None:
694
+ _live_sinks.append(episodic)
695
+ _live_sinks.append(log_sink(root))
696
+ if metrics is not None:
697
+ _live_sinks.append(metrics)
698
+ if monitor_sink is not None: # live mode must feed the web monitor too (was silently omitted)
699
+ _live_sinks.append(monitor_sink)
700
+ _live_sinks.append(sink)
701
+ live_dispatch = make_dispatcher(*_live_sinks)
702
+ llm.set_delta_sink(sink.on_delta)
703
+ import time as _t
704
+ _t0 = _t.monotonic()
705
+ from . import recovery as _rec
706
+ result = run_turn(build_slice=build, llm=llm, tools=tools, dispatch=live_dispatch,
707
+ hooks=hooks, signal=signal, max_steps=cfg.max_steps,
708
+ consolidate=lambda: consolidate_checkpoint(session.active(), compact=False),
709
+ checkpoint=lambda m, s, _g=text: _rec.record(root, goal=_g, messages=m, step=s))
710
+ _rec.clear(root) # clean/parked exit → drop the WAL
711
+ _stats["last_turn_s"] = _t.monotonic() - _t0
712
+ if getattr(memory, "is_durable", False):
713
+ from .taskstate import slice_to_task_state
714
+ memory.checkpoint_task(slice_to_task_state(
715
+ session.active(), session.active_id, session_id=session.session_id,
716
+ status="done" if result.stop_reason == "end_turn" else "parked"))
717
+ if reviewer is not None:
718
+ reviewer.review(session.session_id)
719
+
720
+ def _try_live() -> bool:
721
+ """Run the always-pinned live composer; return True if it ran (REPL below is then skipped), False to
722
+ fall back to the REPL on any startup failure — input is never left broken."""
723
+ try:
724
+ _tui.run_live(console=_console, stats=_stats, banner_info=info, root=root,
725
+ run_one_turn=_run_one_turn, handle_slash=_handle_slash)
726
+ return True
727
+ except Exception as _e: # noqa: BLE001
728
+ print(f"\n live UI failed ({type(_e).__name__}: {_e}); using the inline REPL instead.")
729
+ return False
730
+
731
+ # crash recovery: a leftover WAL means the last turn in this workspace never reached a clean/parked
732
+ # exit (a hard crash). Surface what was in flight, then clear it (auto-loop-resume is a future step).
733
+ from . import recovery
734
+ _pend = recovery.pending(root)
735
+ if _pend:
736
+ _rg = one_line(_pend.get("goal", ""), 60)
737
+ _rla = one_line(recovery.last_assistant(_pend), 160)
738
+ _note = (f" ⚠ recovered an interrupted turn (step {_pend.get('step', '?')}): {_rg}"
739
+ + (f"\n last said: {_rla}" if _rla else "")
740
+ + "\n its progress wasn't saved cleanly — re-send the request to continue.")
741
+ # markup=False: _note holds RECOVERED agent/user text (paths like app/jobs/[id]/page.tsx, code, or a
742
+ # stray `[/learn]`) — parsing it as Rich markup crashes startup with a MarkupError. Style, don't parse.
743
+ (_console.print(_note, style="yellow", markup=False) if _console is not None else print(_note))
744
+ recovery.clear(root)
745
+
746
+ if use_live and _try_live():
747
+ pass # the live composer ran the whole session (until ctrl-d/exit)
748
+ else:
749
+ if _tui:
750
+ _tui.banner(_console, info)
751
+ else:
752
+ print("sliceagent · slice core (run_turn) · " + info)
753
+ print('type a task, or "exit" to quit\n')
754
+ from .sensory_cortex import project_root as _project_root
755
+ if _project_root(root) is None: # launched outside a project → tell the user how to pick one
756
+ _hint = " · no project here — type /cwd <path> to set your workspace (or just chat / ask me to find one)"
757
+ (_console.print(f"[grey50]{_hint}[/]") if _console is not None else print(_hint))
758
+ while True:
759
+ if _input is not None:
760
+ line = _input.prompt()
761
+ if line is None: # ctrl-d / EOF
762
+ break
763
+ line = line.strip()
764
+ else:
765
+ try:
766
+ line = input("You: ").strip()
767
+ except (EOFError, KeyboardInterrupt):
768
+ print()
769
+ break
770
+ if line in ("exit", "quit", "/exit"):
771
+ break
772
+ if not line:
773
+ continue
774
+ if line == "/learn" or line.startswith("/learn "): # transcript → reusable skill (runs as a turn)
775
+ from .neocortex import build_learn_prompt
776
+ line = build_learn_prompt(line[len("/learn"):].strip())
777
+ elif _tui and line.startswith("/"): # navigation palette (no turn)
778
+ _handle_slash(line)
779
+ continue
780
+ # INVARIANT: echo the user's line BEFORE any blocking work (esp. route_topic's LLM round-trip),
781
+ # so the message paints the instant Enter is pressed — not ~0.5-2s later.
782
+ if _tui: # anchor the user turn with spacing (fixes cramped layout)
783
+ _tui.user_echo(_console, line)
784
+ if is_chitchat(line): # 'hi'/'thanks' → cheap reply, skip routing/slice/run_turn (item D)
785
+ _chitchat_reply(line, dispatch)
786
+ continue
787
+ if session.active_id is None: # first message bootstraps the first topic
788
+ session.new_topic(line)
789
+ else: # route: continue / new / resume (no junk topic)
790
+ # route() is lexical by default (instant, zero round-trips); AGENT_ROUTER=llm restores the
791
+ # classifier (a provider round-trip). Cover it with a 'routing…' spinner so the llm mode has
792
+ # no silent freeze before run_turn's own 'thinking…' spinner (which only starts at SliceBuilt).
793
+ if _tui:
794
+ with _console.status("[grey50]routing…[/]", spinner="dots"):
795
+ action, tid = route(llm, line, session)
796
+ else:
797
+ action, tid = route(llm, line, session)
798
+ if action == "new":
799
+ session.new_topic(line)
800
+ elif action == "resume":
801
+ session.switch_topic(tid)
802
+ session.continue_topic(line, resume=True)
803
+ else:
804
+ session.continue_topic(line)
805
+ if not _tui: # TUI shows the topic in the status bar, not as noise
806
+ print(f" · topic: {action}{(' ' + tid) if tid else ''}")
807
+ _stats["topic"] = one_line(session.active().goal, 40) if session.active_id else ""
808
+ record_user(session.active(), line) # short-range continuity: the RECENT CONVERSATION tier
809
+ _expand_mentions(line) # @path → pin the file into OPEN FILES
810
+ build = make_build_slice(session, tools, retriever, memory, line, session.session_id)
811
+ if os.environ.get("AGENT_TIMING"): # per-turn latency breakdown (build vs model) → find the hang
812
+ import time as _tt
813
+ _b = build
814
+ def build(_b=_b):
815
+ _s = _tt.monotonic()
816
+ r = _b()
817
+ print(f" ⏱ slice build {(_tt.monotonic() - _s) * 1000:.0f} ms (spinner appears here; "
818
+ "the rest of the wait is the model's first token)", flush=True)
819
+ return r
820
+ # ctrl-c OR esc during the turn (incl. while the LLM is thinking) raises KeyboardInterrupt, which
821
+ # run_turn catches → aborts the turn cleanly and returns here to the prompt (then ctrl-d quits).
822
+ # Esc is translated to a real SIGINT by a narrow background sentinel (a no-op on non-tty/eval —
823
+ # start() gates itself exactly like _arrow_select does), so it reaches the SAME KeyboardInterrupt
824
+ # path ctrl-c already uses instead of a separate abort mechanism.
825
+ _esc = _tui.make_esc_sentinel() if _tui else None
826
+ if _esc is not None:
827
+ _esc.start()
828
+ import time as _time
829
+ _t0 = _time.monotonic()
830
+ try:
831
+ result = run_turn(build_slice=build, llm=llm, tools=tools, dispatch=dispatch, hooks=hooks,
832
+ max_steps=cfg.max_steps,
833
+ consolidate=lambda: consolidate_checkpoint(session.active(), compact=False),
834
+ checkpoint=lambda m, s, _g=line: recovery.record(root, goal=_g, messages=m, step=s))
835
+ finally:
836
+ if _esc is not None:
837
+ _esc.stop()
838
+ recovery.clear(root) # clean/parked exit → drop the WAL
839
+ _stats["last_turn_s"] = _time.monotonic() - _t0 # shown as ⏲ in the status bar
840
+ if getattr(memory, "is_durable", False): # durable checkpoint (no-op under NullMemory)
841
+ from .taskstate import slice_to_task_state
842
+ memory.checkpoint_task(slice_to_task_state(
843
+ session.active(), session.active_id, session_id=session.session_id,
844
+ status="done" if result.stop_reason == "end_turn" else "parked"))
845
+ if reviewer is not None: # OPT-IN: critique the turn off-thread
846
+ reviewer.review(session.session_id)
847
+
848
+ # session end: tear down background procs / PTY sessions, MCP servers, and consolidate memory. Each
849
+ # step is GUARDED (a failure warns, never crashes the exit) and the MCP shutdown is BOUNDED by a
850
+ # timeout, so a stuck server or index write can never freeze the process on the way out.
851
+ def _safe(label, fn):
852
+ try:
853
+ return fn()
854
+ except Exception as _e: # noqa: BLE001
855
+ print(f" · warning: {label} failed ({type(_e).__name__}: {_e})")
856
+ return None
857
+
858
+ def _bounded(label, fn, secs=8.0):
859
+ import threading as _th
860
+ t = _th.Thread(target=lambda: _safe(label, fn), daemon=True)
861
+ t.start(); t.join(secs)
862
+ if t.is_alive():
863
+ print(f" · warning: {label} timed out after {secs:.0f}s — exiting anyway")
864
+
865
+ _safe("tool cleanup", base_tools.cleanup)
866
+ if reviewer is not None: # let an in-flight background review finish (bounded) before consolidating
867
+ _safe("bg-review join", lambda: reviewer.join(timeout=10))
868
+ if mcp_runtime is not None: # #61/#62: a stuck MCP server must not freeze exit → bounded shutdown
869
+ _bounded("MCP shutdown", mcp_runtime.shutdown)
870
+ # consolidate the episodic cache into long-term memory (the cache→memory loop). mine_mode gates it:
871
+ # off → skip; deterministic → recorded skills; llm → render_skill_llm generalizes (scan-first).
872
+ if getattr(memory, "is_durable", False) and mine_mode not in ("0", "off", "none"):
873
+ st = _safe("memory consolidation",
874
+ lambda: memory.consolidate(session.session_id, llm=llm, mode=mine_mode)) or {}
875
+ if st.get("lessons") or st.get("skills"): # report the TRUTH, not a blind 'success'
876
+ print(f" · consolidated: {st.get('lessons', 0)} lesson(s), {st.get('skills', 0)} skill(s)"
877
+ + (f", {st['skills_rejected']} rejected" if st.get("skills_rejected") else "")
878
+ + (f", {st['errors']} error(s)" if st.get("errors") else ""))
879
+ elif st.get("skills_rejected") or st.get("errors"):
880
+ print(f" · consolidation: {st.get('skills_rejected', 0)} rejected, {st.get('errors', 0)} error(s)")
881
+ _safe("memory close", getattr(memory, "close", lambda: None)) # #33: close the FTS5 index (WAL checkpoint)
882
+ if metrics is not None: # the moat number: per-turn fresh-input curve
883
+ s = metrics.summary()
884
+ print(f" · metrics: per_turn_fresh={s['per_turn_fresh']} avg={s['avg_turn_fresh']} "
885
+ f"cache_hit={s['cache_hit_rate']} tools={s['tool_calls']}({s['tool_failures']} fail) "
886
+ f"retries={s['retries']} overflows={s['overflows']} errors={s['errors']}")
887
+
888
+
889
+ if __name__ == "__main__":
890
+ main()