system-diff 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.
- system_diff/__init__.py +8 -0
- system_diff/__main__.py +5 -0
- system_diff/agent.py +197 -0
- system_diff/cli.py +214 -0
- system_diff/compare.py +105 -0
- system_diff/config.py +106 -0
- system_diff/diff.py +171 -0
- system_diff/extract.py +279 -0
- system_diff/graph.py +209 -0
- system_diff/mcp_server.py +206 -0
- system_diff/narrate.py +141 -0
- system_diff/pictures.py +550 -0
- system_diff/png.py +121 -0
- system_diff/readme.py +39 -0
- system_diff/sources.py +76 -0
- system_diff/watch.py +177 -0
- system_diff-0.1.0.dist-info/METADATA +131 -0
- system_diff-0.1.0.dist-info/RECORD +21 -0
- system_diff-0.1.0.dist-info/WHEEL +4 -0
- system_diff-0.1.0.dist-info/entry_points.txt +3 -0
- system_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""`system-diff mcp`: system-diff as an MCP server over stdio, for Claude Code and other MCP clients.
|
|
2
|
+
|
|
3
|
+
An optional extra (`pip install "system-diff[mcp]"`): the official MCP SDK is its only dependency. The tools read the
|
|
4
|
+
repository the client runs in, and write pictures under .git/system-diff/, which is never committed. They return the
|
|
5
|
+
exact text (headline, plan) for the model to explain from, and the picture's path for the person.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
try: # the MCP SDK's 2.x name; 1.x called it FastMCP, with the same constructor, decorator and run()
|
|
15
|
+
from mcp.server.mcpserver import MCPServer as Server
|
|
16
|
+
except ImportError: # pragma: no cover - mcp 1.x
|
|
17
|
+
from mcp.server.fastmcp import FastMCP as Server
|
|
18
|
+
|
|
19
|
+
from . import compare as cmp
|
|
20
|
+
from . import config, graph, pictures
|
|
21
|
+
from .extract import extract
|
|
22
|
+
from .sources import GitSource, git, repo_root
|
|
23
|
+
|
|
24
|
+
INSTRUCTIONS = (
|
|
25
|
+
"system-diff shows what a change does to a system's structure: its parts, entry points, connections, outside "
|
|
26
|
+
"systems and stored data. Use system_diff after changing the code, to tell the person in plain words what changed "
|
|
27
|
+
"in the system and to point them at the picture; base 'checkpoint' is what they last marked as seen. Use "
|
|
28
|
+
"system_map for the whole system, and draw for a picture of something else (a process, a sequence). Call "
|
|
29
|
+
"checkpoint only when the person says they have seen and understood the current state."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Code(BaseModel):
|
|
34
|
+
code: str = Field(description="shown in a monospace font, e.g. a route or a table")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Line = Union[str, Code]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Spec(BaseModel):
|
|
41
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Node(_Spec):
|
|
45
|
+
id: str
|
|
46
|
+
col: str = Field(description="the key of one of `columns`")
|
|
47
|
+
title: str
|
|
48
|
+
sub: str = ""
|
|
49
|
+
type: Literal["component", "store", "host"] = "component"
|
|
50
|
+
style: Literal["", "new", "legacy", "shared", "undeclared"] = ""
|
|
51
|
+
lines: List[Line] = []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Edge(_Spec):
|
|
55
|
+
from_: str = Field(alias="from")
|
|
56
|
+
to: str
|
|
57
|
+
kind: Literal["call", "data", "network"] = "call"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Stage(_Spec):
|
|
61
|
+
title: str
|
|
62
|
+
lines: List[Line] = []
|
|
63
|
+
hi: bool = Field(False, description="highlight this stage")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Loop(_Spec):
|
|
67
|
+
from_: int = Field(alias="from", description="index of the stage it goes back from")
|
|
68
|
+
to: int = Field(description="index of the stage it goes back to")
|
|
69
|
+
label: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Exit(_Spec):
|
|
73
|
+
label: str
|
|
74
|
+
note: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Gate(_Spec):
|
|
78
|
+
text: str = Field(description="where a person comes in, e.g. 'A person approves'")
|
|
79
|
+
from_: Optional[List[int]] = Field(None, alias="from", description="indexes of the stages that lead to it")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Lane(_Spec):
|
|
83
|
+
id: str
|
|
84
|
+
title: str
|
|
85
|
+
sub: str = ""
|
|
86
|
+
person: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Step(_Spec):
|
|
90
|
+
from_: Optional[str] = Field(None, alias="from", description="a lane id (a message step)")
|
|
91
|
+
to: Optional[str] = Field(None, description="a lane id (a message step)")
|
|
92
|
+
text: Optional[str] = Field(None, description="what is sent (a message step)")
|
|
93
|
+
kind: Optional[Literal["call", "data", "network"]] = None
|
|
94
|
+
note: Optional[str] = Field(None, description="a note across lanes instead of a message")
|
|
95
|
+
over: Optional[List[str]] = Field(None, description="the lane ids a note spans")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Spec(_Spec):
|
|
99
|
+
"""One picture. `map` needs columns and nodes (edges optional); `stages` needs stages; `sequence` needs lanes
|
|
100
|
+
and steps."""
|
|
101
|
+
picture: Literal["map", "stages", "sequence"]
|
|
102
|
+
label: str = ""
|
|
103
|
+
columns: Optional[List[List[str]]] = Field(None, description='map: [["logic", "Logic"], ["store", "Stored data"]]')
|
|
104
|
+
nodes: Optional[List[Node]] = None
|
|
105
|
+
edges: Optional[List[Edge]] = None
|
|
106
|
+
stages: Optional[List[Stage]] = None
|
|
107
|
+
loops: Optional[List[Loop]] = None
|
|
108
|
+
exits: Optional[List[Exit]] = None
|
|
109
|
+
gate: Optional[Gate] = None
|
|
110
|
+
lanes: Optional[List[Lane]] = None
|
|
111
|
+
steps: Optional[List[Step]] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _out(root: Path, *parts: str) -> Path:
|
|
115
|
+
"""A folder under .git/system-diff (the repository's own git dir, worktrees included)."""
|
|
116
|
+
gitdir = Path(git("rev-parse", "--absolute-git-dir", cwd=root).strip())
|
|
117
|
+
out = gitdir.joinpath("system-diff", *parts)
|
|
118
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def tool_map(root: Path, ref: str = ".") -> Dict[str, Any]:
|
|
123
|
+
sha, label = cmp.resolve(root, ref)
|
|
124
|
+
source = GitSource(sha, root)
|
|
125
|
+
cfg = config.load(source, root)
|
|
126
|
+
bp = extract(source, cfg)
|
|
127
|
+
path = _out(root, "mcp") / "system.svg"
|
|
128
|
+
pictures.write_if_changed(path, graph.system_svg(bp, cfg))
|
|
129
|
+
comps = bp["components"]
|
|
130
|
+
return {"of": label, "picture": str(path),
|
|
131
|
+
"parts": [{"id": k, "label": v["label"], "column": graph.part_kind(k, v, bp, cfg), "files": v["files"]}
|
|
132
|
+
for k, v in sorted(comps.items(), key=lambda kv: graph.part_order(kv[1], kv[0]))],
|
|
133
|
+
"connections": [f"{comps.get(s, {}).get('label', s)} → {comps.get(t, {}).get('label', t)}"
|
|
134
|
+
for s, t in (k.split(">") for k in bp["flows"])],
|
|
135
|
+
"entry_points": len(bp["routes"]), "tables": len(bp["tables"]),
|
|
136
|
+
"outside_systems": [v["label"] for v in bp["externals"].values()],
|
|
137
|
+
"read_but_not_deployed": [v["label"] for v in bp["dormant"].values()],
|
|
138
|
+
"undeclared_parts": sorted(k for k, v in comps.items() if not v["declared"])}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def tool_diff(root: Path, base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
|
|
142
|
+
c = cmp.compare(root, base, head)
|
|
143
|
+
svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg)
|
|
144
|
+
path = _out(root, "mcp") / "diff.svg"
|
|
145
|
+
if svg:
|
|
146
|
+
pictures.write_if_changed(path, svg)
|
|
147
|
+
elif path.is_file():
|
|
148
|
+
path.unlink()
|
|
149
|
+
return {"from": c.base, "to": c.head, "headline": c.diff["headline"], "plan": c.diff["plan"],
|
|
150
|
+
"changes": len(c.diff["changes"]), "impacts": c.diff["impacts"],
|
|
151
|
+
"picture": str(path) if svg else None,
|
|
152
|
+
"live_page": "run `system-diff watch` for a page that redraws as the code changes"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def tool_draw(root: Path, spec: Spec, name: str = "picture") -> Dict[str, Any]:
|
|
156
|
+
data = spec.model_dump(by_alias=True, exclude_none=True)
|
|
157
|
+
problems = pictures.check(data)
|
|
158
|
+
if problems:
|
|
159
|
+
return {"error": "the spec could not be drawn: " + "; ".join(problems), "do": "fix these and draw again"}
|
|
160
|
+
try:
|
|
161
|
+
svg = pictures.draw(data)
|
|
162
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
163
|
+
return {"error": f"the spec could not be drawn: {type(exc).__name__}: {exc}",
|
|
164
|
+
"do": "check that every edge, loop, gate and step names an id or index the spec has"}
|
|
165
|
+
safe = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in name)[:60] or "picture"
|
|
166
|
+
path = _out(root, "pictures") / f"{safe}.svg"
|
|
167
|
+
pictures.write_if_changed(path, svg)
|
|
168
|
+
return {"picture": str(path)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build() -> Server:
|
|
172
|
+
server = Server("system-diff", instructions=INSTRUCTIONS)
|
|
173
|
+
|
|
174
|
+
@server.tool()
|
|
175
|
+
def system_map(ref: str = ".") -> Dict[str, Any]:
|
|
176
|
+
"""The system's structure: its parts by column, the connections between them, how many entry points and
|
|
177
|
+
tables, and its outside systems. `ref`: a commit, or "." for the working tree. Returns the picture's path."""
|
|
178
|
+
return tool_map(repo_root(), ref)
|
|
179
|
+
|
|
180
|
+
@server.tool()
|
|
181
|
+
def system_diff(base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
|
|
182
|
+
"""What changed in the system from `base` to `head`: one sentence, the exact list (each line with its impact,
|
|
183
|
+
e.g. new-interface, breaking-interface, data-schema-change, test-gap) and a picture of only what changed.
|
|
184
|
+
`base` "checkpoint" is what the person last marked as seen (HEAD if nothing yet); `head` "." is the working
|
|
185
|
+
tree, uncommitted files included. Explain it from the plan, never beyond it."""
|
|
186
|
+
return tool_diff(repo_root(), base, head)
|
|
187
|
+
|
|
188
|
+
@server.tool()
|
|
189
|
+
def draw(spec: Spec, name: str = "picture") -> Dict[str, Any]:
|
|
190
|
+
"""Draw a picture in system-diff's look: a `map` (boxes in columns), `stages` (a process left to right, with
|
|
191
|
+
loops back, exits and where a person comes in) or a `sequence` (who talks to whom, in order). Returns the
|
|
192
|
+
SVG's path, or what is wrong with the spec."""
|
|
193
|
+
return tool_draw(repo_root(), spec, name)
|
|
194
|
+
|
|
195
|
+
@server.tool()
|
|
196
|
+
def checkpoint(ref: str = "") -> Dict[str, Any]:
|
|
197
|
+
"""Mark the working tree (or `ref`) as seen by the person, so the next system_diff from "checkpoint" shows
|
|
198
|
+
only what is new to them. Only when the person says they have seen and understood the current state."""
|
|
199
|
+
sha = cmp.checkpoint(repo_root(), ref or None)
|
|
200
|
+
return {"checkpoint": sha[:9], "of": ref or "the working tree"}
|
|
201
|
+
|
|
202
|
+
return server
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main() -> None:
|
|
206
|
+
build().run("stdio")
|
system_diff/narrate.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The optional plain-words summary on top of the exact diff, written by an LLM; the pictures never depend on it.
|
|
2
|
+
|
|
3
|
+
Which LLM: the one your Claude Code uses (its settings files' `env` blocks, layered as Claude Code layers them, with
|
|
4
|
+
ANTHROPIC_* in the environment over all of them); else an OpenAI-compatible gateway exported in your own shell
|
|
5
|
+
(SYSTEM_DIFF_LLM_BASE_URL / _API_KEY / _MODEL, or LLM_*). Only the headline, the exact plan and the commit messages are
|
|
6
|
+
sent: never the code. Nothing answers, no summary.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
import ssl
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
DEFAULT_MODEL = "claude-sonnet-5-5"
|
|
23
|
+
|
|
24
|
+
SYSTEM = (
|
|
25
|
+
"You explain to the reviewers of a pull request what it does to a system's architecture. You are given an "
|
|
26
|
+
"exact, machine-extracted structural diff and the commit messages. Write two parts, plain words, no headings: "
|
|
27
|
+
"1) two to four sentences on what changes in the system and why; 2) up to four bullet points a reviewer should "
|
|
28
|
+
"check, each grounded in a line of the diff. Never mention a component, endpoint, table or system that is not "
|
|
29
|
+
"in the diff. If the diff shows no structural change, say so in one sentence."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def claude_settings_files(root: Path, extra: List[str] = ()) -> List[Tuple[Path, str]]:
|
|
34
|
+
"""Claude Code settings that can carry the gateway, lowest precedence first: user, user-local, project,
|
|
35
|
+
project-local, then any the config adds."""
|
|
36
|
+
home, repo = Path.home() / ".claude", root / ".claude"
|
|
37
|
+
files = [(home / "settings.json", "~/.claude/settings.json"),
|
|
38
|
+
(home / "settings.local.json", "~/.claude/settings.local.json"),
|
|
39
|
+
(repo / "settings.json", ".claude/settings.json"),
|
|
40
|
+
(repo / "settings.local.json", ".claude/settings.local.json")]
|
|
41
|
+
return files + [(Path(os.path.expanduser(p)), p) for p in extra]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def claude_llm(root: Path, extra: List[str] = ()) -> Optional[Dict[str, Any]]:
|
|
45
|
+
"""The gateway your Claude Code uses; whatever model it names is the model used."""
|
|
46
|
+
env: Dict[str, str] = {}
|
|
47
|
+
source = ""
|
|
48
|
+
for path, label in claude_settings_files(root, extra):
|
|
49
|
+
try:
|
|
50
|
+
block = json.loads(path.read_text(encoding="utf-8")).get("env", {}) or {}
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
env.update(block)
|
|
54
|
+
if block.get("ANTHROPIC_BASE_URL") or block.get("ANTHROPIC_API_KEY"):
|
|
55
|
+
source = label
|
|
56
|
+
for k, v in os.environ.items():
|
|
57
|
+
if k.startswith("ANTHROPIC_") and v:
|
|
58
|
+
env[k] = v
|
|
59
|
+
if k in ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
|
60
|
+
source = "ANTHROPIC_* in the environment"
|
|
61
|
+
token = env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY", "")
|
|
62
|
+
if not token:
|
|
63
|
+
return None
|
|
64
|
+
base = (env.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
|
|
65
|
+
return {"base_url": base, "token": token, "model": env.get("ANTHROPIC_MODEL", DEFAULT_MODEL), "source": source}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def shell_llm() -> Optional[Dict[str, Any]]:
|
|
69
|
+
"""An OpenAI-compatible LLM someone exported in their own shell on purpose — never a committed file."""
|
|
70
|
+
env = os.environ
|
|
71
|
+
base = env.get("SYSTEM_DIFF_LLM_BASE_URL") or env.get("LLM_BASE_URL", "")
|
|
72
|
+
key = env.get("SYSTEM_DIFF_LLM_API_KEY") or env.get("LLM_API_KEY", "")
|
|
73
|
+
model = env.get("SYSTEM_DIFF_LLM_MODEL") or env.get("LLM_MODEL", "")
|
|
74
|
+
if base and key and model:
|
|
75
|
+
return {"kind": "openai", "base_url": base.rstrip("/"), "token": key, "model": model,
|
|
76
|
+
"verify": env.get("LLM_SSL_VERIFY", "true").lower() == "true"}
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ask(s: Dict[str, Any], user: str, max_tokens: int, timeout: int) -> str:
|
|
81
|
+
"""One tool-less completion, OpenAI-compatible or Anthropic Messages."""
|
|
82
|
+
if s.get("kind") == "openai":
|
|
83
|
+
url = s["base_url"] + "/chat/completions"
|
|
84
|
+
body: Dict[str, Any] = {"model": s["model"], "max_tokens": max_tokens,
|
|
85
|
+
"messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]}
|
|
86
|
+
headers = {"content-type": "application/json", "authorization": "Bearer " + s["token"]}
|
|
87
|
+
else:
|
|
88
|
+
url = s["base_url"] + "/v1/messages"
|
|
89
|
+
body = {"model": s["model"], "max_tokens": max_tokens, "system": SYSTEM,
|
|
90
|
+
"messages": [{"role": "user", "content": user}]}
|
|
91
|
+
if s.get("thinking"):
|
|
92
|
+
# A reasoning model given no thinking budget can think until max_tokens and return no text at all.
|
|
93
|
+
body["thinking"] = {"type": "enabled", "budget_tokens": s["thinking"]}
|
|
94
|
+
headers = {"content-type": "application/json", "anthropic-version": "2023-06-01",
|
|
95
|
+
"authorization": "Bearer " + s["token"], "x-api-key": s["token"]}
|
|
96
|
+
# Fail in seconds, not after the read timeout, when this machine cannot reach the gateway at all.
|
|
97
|
+
u = urlparse(url)
|
|
98
|
+
socket.create_connection((u.hostname, u.port or (443 if u.scheme == "https" else 80)), timeout=5).close()
|
|
99
|
+
context = None if s.get("verify", True) else ssl._create_unverified_context()
|
|
100
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", headers=headers)
|
|
101
|
+
with urllib.request.urlopen(req, timeout=timeout, context=context) as r:
|
|
102
|
+
data = json.loads(r.read())
|
|
103
|
+
if s.get("kind") == "openai":
|
|
104
|
+
text = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
105
|
+
else:
|
|
106
|
+
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
|
107
|
+
return re.sub(r"<think>.*?</think>", "", text, flags=re.S).strip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def narrate(d: dict, commits: str, root: Path, cfg: Dict[str, Any], timeout: int = 120) -> Optional[str]:
|
|
111
|
+
"""The summary, naming the model that wrote it; None when no LLM answers from here."""
|
|
112
|
+
user = f"Headline: {d['headline']}\n\nStructural diff (exact):\n{d['plan']}\n\nCommit messages:\n{commits[:4000]}"
|
|
113
|
+
claude = claude_llm(root, cfg.get("narrate", {}).get("settings", []))
|
|
114
|
+
if claude:
|
|
115
|
+
# With a thinking budget first (some gateways' reasoning models need one); without it if refused.
|
|
116
|
+
for attempt in ({**claude, "thinking": 4000}, claude):
|
|
117
|
+
try:
|
|
118
|
+
text = ask(attempt, user, 12000, timeout)
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
if exc.code == 400 and attempt.get("thinking"):
|
|
121
|
+
continue # this model or gateway takes no thinking parameter
|
|
122
|
+
print(f"system-diff: {claude['model']} via {claude['source']}: HTTP {exc.code}", file=sys.stderr)
|
|
123
|
+
break
|
|
124
|
+
except (OSError, ValueError) as exc: # not reachable from here, or not JSON
|
|
125
|
+
print(f"system-diff: {claude['model']} via {claude['source']}: {type(exc).__name__}: {str(exc)[:120]}",
|
|
126
|
+
file=sys.stderr)
|
|
127
|
+
break
|
|
128
|
+
if text:
|
|
129
|
+
return f"{text[:1500]}\n\n_Summary written by {claude['model']} ({claude['source']}) from the exact diff below._"
|
|
130
|
+
shell = shell_llm()
|
|
131
|
+
if shell:
|
|
132
|
+
for tokens in (2500, 12000):
|
|
133
|
+
try:
|
|
134
|
+
text = ask(shell, user, tokens, timeout)
|
|
135
|
+
except (OSError, ValueError) as exc:
|
|
136
|
+
print(f"system-diff: {shell['model']} via {shell['base_url']}: {type(exc).__name__}: {str(exc)[:120]}",
|
|
137
|
+
file=sys.stderr)
|
|
138
|
+
break
|
|
139
|
+
if text:
|
|
140
|
+
return f"{text[:1500]}\n\n_Summary written by {shell['model']} (an LLM set in this shell) from the exact diff below._"
|
|
141
|
+
return None
|