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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. 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