synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Keep multi-turn context continuous after user cancel (ESC).
|
|
2
|
+
|
|
3
|
+
Hard-stopping astream can leave the LangGraph checkpoint with:
|
|
4
|
+
- AIMessage.tool_calls without matching ToolMessage
|
|
5
|
+
- pending ``next`` nodes (e.g. tools / model)
|
|
6
|
+
|
|
7
|
+
The next user turn then fails or loses consistency. This module seals the
|
|
8
|
+
cancelled turn so prior messages remain usable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _msg_type(msg: Any) -> str:
|
|
17
|
+
t = getattr(msg, "type", None)
|
|
18
|
+
if t:
|
|
19
|
+
return str(t).lower()
|
|
20
|
+
return type(msg).__name__.lower()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_tool_msg(msg: Any) -> bool:
|
|
24
|
+
return _msg_type(msg) in {"tool", "toolmessage"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_ai_msg(msg: Any) -> bool:
|
|
28
|
+
return _msg_type(msg) in {"ai", "assistant", "aimessage"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_human_msg(msg: Any) -> bool:
|
|
32
|
+
return _msg_type(msg) in {"human", "user", "humanmessage"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _tool_call_id(call: Any) -> str | None:
|
|
36
|
+
if isinstance(call, dict):
|
|
37
|
+
cid = call.get("id")
|
|
38
|
+
else:
|
|
39
|
+
cid = getattr(call, "id", None)
|
|
40
|
+
return str(cid) if cid else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tool_call_name(call: Any) -> str:
|
|
44
|
+
if isinstance(call, dict):
|
|
45
|
+
name = call.get("name")
|
|
46
|
+
else:
|
|
47
|
+
name = getattr(call, "name", None)
|
|
48
|
+
return str(name or "tool")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _graph_nodes(agent: Any) -> set[str]:
|
|
52
|
+
nodes = getattr(agent, "nodes", None) or {}
|
|
53
|
+
try:
|
|
54
|
+
return set(nodes.keys())
|
|
55
|
+
except Exception: # noqa: BLE001
|
|
56
|
+
return set()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pick_tools_node(nodes: set[str], next_nodes: tuple[str, ...]) -> str | None:
|
|
60
|
+
for n in next_nodes:
|
|
61
|
+
if "tool" in n.lower():
|
|
62
|
+
return n
|
|
63
|
+
if "tools" in nodes:
|
|
64
|
+
return "tools"
|
|
65
|
+
for n in nodes:
|
|
66
|
+
if "tool" in n.lower() and not n.startswith("__"):
|
|
67
|
+
return n
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pick_model_node(nodes: set[str], next_nodes: tuple[str, ...]) -> str | None:
|
|
72
|
+
for n in next_nodes:
|
|
73
|
+
low = n.lower()
|
|
74
|
+
if "tool" not in low and not n.startswith("__"):
|
|
75
|
+
return n
|
|
76
|
+
for preferred in ("model", "agent"):
|
|
77
|
+
if preferred in nodes:
|
|
78
|
+
return preferred
|
|
79
|
+
for n in nodes:
|
|
80
|
+
if not n.startswith("__") and "tool" not in n.lower() and "middleware" not in n.lower():
|
|
81
|
+
return n
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _answered_tool_ids(messages: list[Any]) -> set[str]:
|
|
86
|
+
done: set[str] = set()
|
|
87
|
+
for msg in messages:
|
|
88
|
+
if not _is_tool_msg(msg):
|
|
89
|
+
continue
|
|
90
|
+
tid = getattr(msg, "tool_call_id", None)
|
|
91
|
+
if tid:
|
|
92
|
+
done.add(str(tid))
|
|
93
|
+
return done
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pending_tool_seals(messages: list[Any]) -> list[Any]:
|
|
97
|
+
from langchain_core.messages import ToolMessage
|
|
98
|
+
|
|
99
|
+
answered = _answered_tool_ids(messages)
|
|
100
|
+
seals: list[Any] = []
|
|
101
|
+
for msg in messages:
|
|
102
|
+
for call in getattr(msg, "tool_calls", None) or []:
|
|
103
|
+
cid = _tool_call_id(call)
|
|
104
|
+
if not cid or cid in answered:
|
|
105
|
+
continue
|
|
106
|
+
seals.append(
|
|
107
|
+
ToolMessage(
|
|
108
|
+
content="[cancelled by user]",
|
|
109
|
+
tool_call_id=cid,
|
|
110
|
+
name=_tool_call_name(call),
|
|
111
|
+
status="error",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
answered.add(cid)
|
|
115
|
+
return seals
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _needs_cancel_note(messages: list[Any]) -> bool:
|
|
119
|
+
if not messages:
|
|
120
|
+
return False
|
|
121
|
+
last = messages[-1]
|
|
122
|
+
content = str(getattr(last, "content", "") or "")
|
|
123
|
+
low = content.casefold()
|
|
124
|
+
if _is_ai_msg(last) and not (getattr(last, "tool_calls", None) or []):
|
|
125
|
+
if "cancelled" in low or "终止" in content:
|
|
126
|
+
return False
|
|
127
|
+
# Plain AI reply already closed the turn.
|
|
128
|
+
if content.strip():
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
# Human without answer, tool without follow-up AI, or AI with open tools
|
|
132
|
+
# (tools should already be sealed before this check).
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def repair_thread_after_cancel(agent: Any, config: dict[str, Any]) -> list[str]:
|
|
137
|
+
"""Seal a cancelled turn so the same thread_id remains continuous.
|
|
138
|
+
|
|
139
|
+
Returns short status notes for logs/UI (never raises).
|
|
140
|
+
"""
|
|
141
|
+
notes: list[str] = []
|
|
142
|
+
get_state = getattr(agent, "get_state", None)
|
|
143
|
+
update_state = getattr(agent, "update_state", None)
|
|
144
|
+
if not callable(get_state) or not callable(update_state):
|
|
145
|
+
return ["cancel seal skipped: agent has no get_state/update_state"]
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
snap = get_state(config)
|
|
149
|
+
except Exception as exc: # noqa: BLE001
|
|
150
|
+
return [f"cancel seal skipped: get_state failed: {exc}"]
|
|
151
|
+
|
|
152
|
+
values = getattr(snap, "values", None) or {}
|
|
153
|
+
messages = list(values.get("messages") or [])
|
|
154
|
+
if not messages:
|
|
155
|
+
return ["cancel seal: empty thread (nothing to preserve)"]
|
|
156
|
+
|
|
157
|
+
nodes = _graph_nodes(agent)
|
|
158
|
+
next_nodes = tuple(str(x) for x in (getattr(snap, "next", None) or ()))
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
seals = _pending_tool_seals(messages)
|
|
162
|
+
if seals:
|
|
163
|
+
tools_node = _pick_tools_node(nodes, next_nodes)
|
|
164
|
+
if tools_node:
|
|
165
|
+
update_state(config, {"messages": seals}, as_node=tools_node)
|
|
166
|
+
notes.append(f"sealed {len(seals)} open tool call(s) as_node={tools_node}")
|
|
167
|
+
else:
|
|
168
|
+
update_state(config, {"messages": seals})
|
|
169
|
+
notes.append(f"sealed {len(seals)} open tool call(s)")
|
|
170
|
+
snap = get_state(config)
|
|
171
|
+
values = getattr(snap, "values", None) or {}
|
|
172
|
+
messages = list(values.get("messages") or [])
|
|
173
|
+
next_nodes = tuple(str(x) for x in (getattr(snap, "next", None) or ()))
|
|
174
|
+
|
|
175
|
+
if _needs_cancel_note(messages):
|
|
176
|
+
from langchain_core.messages import AIMessage
|
|
177
|
+
|
|
178
|
+
note = AIMessage(content="[本轮已由用户终止,上下文已保留]")
|
|
179
|
+
model_node = _pick_model_node(nodes, next_nodes)
|
|
180
|
+
if model_node and next_nodes:
|
|
181
|
+
try:
|
|
182
|
+
update_state(config, {"messages": [note]}, as_node=model_node)
|
|
183
|
+
notes.append(f"cancel note as_node={model_node}")
|
|
184
|
+
except Exception: # noqa: BLE001
|
|
185
|
+
update_state(config, {"messages": [note]})
|
|
186
|
+
notes.append("cancel note appended")
|
|
187
|
+
else:
|
|
188
|
+
# No pending graph step: still append a boundary note when the
|
|
189
|
+
# last message is human/tool so the next model call is clean.
|
|
190
|
+
last = messages[-1]
|
|
191
|
+
if _is_human_msg(last) or _is_tool_msg(last) or (
|
|
192
|
+
_is_ai_msg(last) and (getattr(last, "tool_calls", None) or [])
|
|
193
|
+
):
|
|
194
|
+
update_state(config, {"messages": [note]})
|
|
195
|
+
notes.append("cancel note appended")
|
|
196
|
+
|
|
197
|
+
snap = get_state(config)
|
|
198
|
+
values = getattr(snap, "values", None) or {}
|
|
199
|
+
messages = list(values.get("messages") or [])
|
|
200
|
+
next_nodes = tuple(str(x) for x in (getattr(snap, "next", None) or ()))
|
|
201
|
+
notes.append(f"messages={len(messages)}")
|
|
202
|
+
if next_nodes:
|
|
203
|
+
notes.append(f"pending next={next_nodes}")
|
|
204
|
+
else:
|
|
205
|
+
notes.append("checkpoint ready for next turn")
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
notes.append(f"cancel seal error: {exc}")
|
|
208
|
+
return notes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Session recap (away summary) — one-line progress when idle.
|
|
2
|
+
|
|
3
|
+
Inspired by Claude Code Session Recap: after a completed turn, if the
|
|
4
|
+
session sits idle long enough, surface a single-line summary so the user
|
|
5
|
+
can resume without scrolling. No slash command — auto-only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
DEFAULT_IDLE_SECONDS = 180.0
|
|
14
|
+
DEFAULT_MIN_TURNS = 3
|
|
15
|
+
MAX_LINE_CHARS = 120
|
|
16
|
+
MAX_FRAGMENT_CHARS = 48
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _one_line(text: str, *, limit: int = MAX_FRAGMENT_CHARS) -> str:
|
|
20
|
+
body = " ".join((text or "").split())
|
|
21
|
+
if not body:
|
|
22
|
+
return ""
|
|
23
|
+
if len(body) <= limit:
|
|
24
|
+
return body
|
|
25
|
+
return body[: max(0, limit - 1)] + "…"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_recap_line(
|
|
29
|
+
*,
|
|
30
|
+
user_text: str = "",
|
|
31
|
+
tool_summary: str = "",
|
|
32
|
+
answer_excerpt: str = "",
|
|
33
|
+
max_chars: int = MAX_LINE_CHARS,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Compose a single dim UI line from the latest turn snapshot."""
|
|
36
|
+
parts: list[str] = []
|
|
37
|
+
user = _one_line(user_text)
|
|
38
|
+
tools = _one_line(tool_summary, limit=40)
|
|
39
|
+
answer = _one_line(answer_excerpt, limit=56)
|
|
40
|
+
if user:
|
|
41
|
+
parts.append(f"任务 {user}")
|
|
42
|
+
if tools:
|
|
43
|
+
parts.append(f"工具 {tools}")
|
|
44
|
+
if answer:
|
|
45
|
+
parts.append(f"进展 {answer}")
|
|
46
|
+
if not parts:
|
|
47
|
+
parts.append("会话空闲;可继续输入")
|
|
48
|
+
body = ";".join(parts)
|
|
49
|
+
line = f"recap: {body}"
|
|
50
|
+
if len(line) > max_chars:
|
|
51
|
+
line = line[: max(0, max_chars - 1)] + "…"
|
|
52
|
+
return line
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def snapshot_from_turn(
|
|
56
|
+
*,
|
|
57
|
+
user_text: str = "",
|
|
58
|
+
tool_items: list[Any] | None = None,
|
|
59
|
+
tool_summary: str = "",
|
|
60
|
+
answer_text: str = "",
|
|
61
|
+
) -> dict[str, str]:
|
|
62
|
+
"""Normalize turn facts into a plain dict for recap generation."""
|
|
63
|
+
summary = (tool_summary or "").strip()
|
|
64
|
+
if not summary and tool_items:
|
|
65
|
+
names: list[str] = []
|
|
66
|
+
for it in tool_items:
|
|
67
|
+
name = getattr(it, "name", None) or getattr(it, "label", None) or ""
|
|
68
|
+
name = str(name).strip()
|
|
69
|
+
if name and name not in names:
|
|
70
|
+
names.append(name)
|
|
71
|
+
if len(names) >= 4:
|
|
72
|
+
break
|
|
73
|
+
if names:
|
|
74
|
+
summary = ", ".join(names)
|
|
75
|
+
return {
|
|
76
|
+
"user_text": (user_text or "").strip(),
|
|
77
|
+
"tool_summary": summary,
|
|
78
|
+
"answer_excerpt": (answer_text or "").strip(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class SessionRecapController:
|
|
84
|
+
"""Track eligibility for an automatic one-line recap after idle."""
|
|
85
|
+
|
|
86
|
+
enabled: bool = True
|
|
87
|
+
idle_seconds: float = DEFAULT_IDLE_SECONDS
|
|
88
|
+
min_turns: int = DEFAULT_MIN_TURNS
|
|
89
|
+
turn_count: int = 0
|
|
90
|
+
last_turn_done_at: float | None = None
|
|
91
|
+
last_snapshot: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
# After a recap is shown, require a new completed turn before next fire.
|
|
93
|
+
needs_fresh_turn: bool = False
|
|
94
|
+
|
|
95
|
+
def reset(self) -> None:
|
|
96
|
+
self.turn_count = 0
|
|
97
|
+
self.last_turn_done_at = None
|
|
98
|
+
self.last_snapshot = {}
|
|
99
|
+
self.needs_fresh_turn = False
|
|
100
|
+
|
|
101
|
+
def note_turn_done(
|
|
102
|
+
self,
|
|
103
|
+
now: float,
|
|
104
|
+
*,
|
|
105
|
+
user_text: str = "",
|
|
106
|
+
tool_summary: str = "",
|
|
107
|
+
tool_items: list[Any] | None = None,
|
|
108
|
+
answer_text: str = "",
|
|
109
|
+
turn_count: int | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Record a completed agent turn as the recap source."""
|
|
112
|
+
if turn_count is not None:
|
|
113
|
+
self.turn_count = max(0, int(turn_count))
|
|
114
|
+
else:
|
|
115
|
+
self.turn_count += 1
|
|
116
|
+
self.last_turn_done_at = float(now)
|
|
117
|
+
self.last_snapshot = snapshot_from_turn(
|
|
118
|
+
user_text=user_text,
|
|
119
|
+
tool_items=tool_items,
|
|
120
|
+
tool_summary=tool_summary,
|
|
121
|
+
answer_text=answer_text,
|
|
122
|
+
)
|
|
123
|
+
self.needs_fresh_turn = False
|
|
124
|
+
|
|
125
|
+
def eligible(
|
|
126
|
+
self,
|
|
127
|
+
now: float,
|
|
128
|
+
*,
|
|
129
|
+
busy: bool = False,
|
|
130
|
+
draft_nonempty: bool = False,
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Whether an idle recap should fire now."""
|
|
133
|
+
if not self.enabled:
|
|
134
|
+
return False
|
|
135
|
+
if busy or draft_nonempty:
|
|
136
|
+
return False
|
|
137
|
+
if self.needs_fresh_turn:
|
|
138
|
+
return False
|
|
139
|
+
if self.turn_count < max(1, int(self.min_turns)):
|
|
140
|
+
return False
|
|
141
|
+
if self.last_turn_done_at is None:
|
|
142
|
+
return False
|
|
143
|
+
idle_for = float(now) - float(self.last_turn_done_at)
|
|
144
|
+
if idle_for < max(0.0, float(self.idle_seconds)):
|
|
145
|
+
return False
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def try_fire(
|
|
149
|
+
self,
|
|
150
|
+
now: float,
|
|
151
|
+
*,
|
|
152
|
+
busy: bool = False,
|
|
153
|
+
draft_nonempty: bool = False,
|
|
154
|
+
) -> str | None:
|
|
155
|
+
"""If eligible, return the recap line and mark as shown.
|
|
156
|
+
|
|
157
|
+
Long idle must never re-generate: after a successful fire, arm
|
|
158
|
+
``needs_fresh_turn`` and clear the idle clock until a new turn.
|
|
159
|
+
"""
|
|
160
|
+
if not self.eligible(
|
|
161
|
+
now,
|
|
162
|
+
busy=busy,
|
|
163
|
+
draft_nonempty=draft_nonempty,
|
|
164
|
+
):
|
|
165
|
+
return None
|
|
166
|
+
line = build_recap_line(
|
|
167
|
+
user_text=self.last_snapshot.get("user_text", ""),
|
|
168
|
+
tool_summary=self.last_snapshot.get("tool_summary", ""),
|
|
169
|
+
answer_excerpt=self.last_snapshot.get("answer_excerpt", ""),
|
|
170
|
+
)
|
|
171
|
+
# One-shot per completed turn. Further idle ticks stay silent.
|
|
172
|
+
self.needs_fresh_turn = True
|
|
173
|
+
self.last_turn_done_at = None
|
|
174
|
+
return line
|