textllm 20250201.0.0__tar.gz

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.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.1
2
+ Name: textllm
3
+ Version: 20250201.0.0
4
+ Summary: Simple text file based interface to LLMs
5
+ Author: Justin Winokur
6
+ Author-email: Jwink3101@users.noreply.github.com
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: python-dotenv
13
+ Requires-Dist: langchain
14
+
15
+ # textllm
16
+
17
+ This is a **SIMPLE** text-based interface to LLMs. It is not intended to be a general purpose or overly featureful tool. It is just an easy way to call an LLM and save results in a simple format (text/markdown)
18
+
19
+ textllm uses [LangChain][LangChain] to interact with many AI models.
20
+
21
+ [LangChain]:https://www.langchain.com/
22
+
23
+ ## Setup
24
+
25
+ Install from PyPI
26
+
27
+ $ pip install textllm
28
+
29
+ Then depending on needs
30
+
31
+ $ pip install langchain-openai
32
+ $ pip install langchain-anthropic
33
+ ...
34
+
35
+
36
+ ## Usage
37
+
38
+ Create a new text file. See the format description below for more:
39
+
40
+ $ textllm --new mytitle.md
41
+
42
+ That will look something like:
43
+
44
+ # !!AUTO TITLE!!
45
+
46
+ ```toml
47
+ # Optional Settings
48
+ # TOML Format
49
+ model = "openai:gpt-4o"
50
+ temperature = 0.5
51
+
52
+ # END Optional Settings
53
+ ```
54
+
55
+ --- System ---
56
+
57
+ You are a helpful assistant
58
+
59
+ --- User ---
60
+
61
+
62
+ Then (optionally) modify the System prompt and add your query under the user prompt. Then
63
+
64
+ $ textllm mytitle.md
65
+
66
+ and it will (a) update the title, and (b) add the response, with a new user block ready to go, below it. You will need to re-open the text editor when its done.
67
+
68
+ ## Titles and Names
69
+
70
+ As noted in "Format Description", the title is the first line. If "!!AUTO TITLE!!" is in the first line, textllm will generate a title for the document (using the same model). This can be disabled or just manually set the title. To regenerate a title, reset the title to `!!AUTO TITLE!!`.
71
+
72
+ If `--rename` is set, the document will also be renamed for the title. Numbers will be added to the name as needed to avoid conflicts if needed.
73
+
74
+ ## Environment Variables
75
+
76
+ Most behavior is governed by command-line flags but there are a few exceptions.
77
+
78
+ | Variable | Description |
79
+ |--|--|
80
+ |`$TEXTLLM_ENV_PATH` | Path to an environment file for API keys. |
81
+ |`$TEXTLLM_AUTO_RENAME` | Set to "true" to make `--rename` the *default*. Command-line settings will override. |
82
+
83
+ ## Format Description
84
+
85
+ The format is designed to be very simple. An input is broken up into three main parts
86
+
87
+ 1. Title (optional).
88
+ 2. Settings (optional)
89
+ 3. Conversation
90
+
91
+ ### (1) Title:
92
+
93
+ First line of the document. If and only if it contains "!!AUTO TITLE!!", it will be replaced with an appropriate title based on the document (using the LLM).
94
+
95
+ Generally, this is only set once but "!!AUTO TITLE!!" is added back to the first line, it will get refreshed
96
+
97
+ ### (2) Settings
98
+
99
+ Optionally specify settings for the object in [TOML][toml] format inside of a Markdown fenced code block. Do NOT modify the leading comments as they are needed for the correct parseing. All settings are directly passed including 'model'. Model should be in the format of "<provider>:<name>" format where providers are those from LangChain. See [`init_chat_model` docs][init_chat_model] for the naming scheme and needed Python package and [Chat Models][chat models] for more details.
100
+
101
+ Note that they require an API key. It can be specified in the settings or can be set with an environment variable. Alternatively, an environment file can be specified with '$TEXTLLM_ENV_PATH' that may contain all API keys
102
+
103
+ ### (3) Conversation
104
+
105
+ The conversation is with a simple format. There are three block types as demonstrated below. General practice is to specify 'system' at the top and only once but textllm will translate all that are specified.
106
+
107
+ ```text
108
+ --- System ---
109
+
110
+ Enter your system prompt. These are like *super* user blocks.
111
+
112
+ --- User ---
113
+
114
+ The last "User" block is usually the question.
115
+
116
+ --- Assistant ---
117
+
118
+ The response.
119
+ ```
120
+
121
+ Generally, you want the final block to be the new "User" question but it doesn't have to be. Note that a new "--- User ---" heading will be added after the last response.
122
+
123
+ You can escape a block with a leading "\". It will be done if somehow the response also has such a block.
124
+
125
+
126
+ [toml]: https://toml.io/
127
+ [init_chat_model]: https://python.langchain.com/api_reference/langchain/chat_models/langchain.chat_models.base.init_chat_model.html
128
+ [chat models]: https://python.langchain.com/docs/integrations/chat/
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=40.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ from setuptools import setup, find_packages
2
+ import re
3
+ from pathlib import Path
4
+
5
+ # Read the contents of your README file
6
+ this_directory = Path(__file__).parent
7
+ long_description = (this_directory / "readme.md").read_text()
8
+
9
+
10
+ # Extract the version from the module file
11
+ def get_version():
12
+ version_file = this_directory / "textllm.py"
13
+ with open(version_file, "r") as f:
14
+ for line in f:
15
+ match = re.match(r"^__version__ = ['\"]([^'\"]*)['\"]", line)
16
+ if match:
17
+ return match.group(1)
18
+ raise RuntimeError("Version not found in textllm.py")
19
+
20
+
21
+ setup(
22
+ name="textllm", # The name used by pip
23
+ version=get_version(),
24
+ author="Justin Winokur",
25
+ author_email="Jwink3101@users.noreply.github.com",
26
+ description="Simple text file based interface to LLMs",
27
+ long_description=long_description,
28
+ long_description_content_type="text/markdown",
29
+ # url="https://github.com/Jwink3101/textllm",
30
+ packages=find_packages(),
31
+ py_modules=["textllm"],
32
+ classifiers=[
33
+ "Programming Language :: Python :: 3",
34
+ "License :: OSI Approved :: MIT License",
35
+ "Operating System :: OS Independent",
36
+ ],
37
+ python_requires=">=3.8", # Specify the Python version compatibility
38
+ install_requires=[
39
+ "python-dotenv",
40
+ "langchain",
41
+ ],
42
+ entry_points={
43
+ "console_scripts": [
44
+ "textllm=textllm:cli", # Expose the CLI
45
+ ],
46
+ },
47
+ )
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.1
2
+ Name: textllm
3
+ Version: 20250201.0.0
4
+ Summary: Simple text file based interface to LLMs
5
+ Author: Justin Winokur
6
+ Author-email: Jwink3101@users.noreply.github.com
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: python-dotenv
13
+ Requires-Dist: langchain
14
+
15
+ # textllm
16
+
17
+ This is a **SIMPLE** text-based interface to LLMs. It is not intended to be a general purpose or overly featureful tool. It is just an easy way to call an LLM and save results in a simple format (text/markdown)
18
+
19
+ textllm uses [LangChain][LangChain] to interact with many AI models.
20
+
21
+ [LangChain]:https://www.langchain.com/
22
+
23
+ ## Setup
24
+
25
+ Install from PyPI
26
+
27
+ $ pip install textllm
28
+
29
+ Then depending on needs
30
+
31
+ $ pip install langchain-openai
32
+ $ pip install langchain-anthropic
33
+ ...
34
+
35
+
36
+ ## Usage
37
+
38
+ Create a new text file. See the format description below for more:
39
+
40
+ $ textllm --new mytitle.md
41
+
42
+ That will look something like:
43
+
44
+ # !!AUTO TITLE!!
45
+
46
+ ```toml
47
+ # Optional Settings
48
+ # TOML Format
49
+ model = "openai:gpt-4o"
50
+ temperature = 0.5
51
+
52
+ # END Optional Settings
53
+ ```
54
+
55
+ --- System ---
56
+
57
+ You are a helpful assistant
58
+
59
+ --- User ---
60
+
61
+
62
+ Then (optionally) modify the System prompt and add your query under the user prompt. Then
63
+
64
+ $ textllm mytitle.md
65
+
66
+ and it will (a) update the title, and (b) add the response, with a new user block ready to go, below it. You will need to re-open the text editor when its done.
67
+
68
+ ## Titles and Names
69
+
70
+ As noted in "Format Description", the title is the first line. If "!!AUTO TITLE!!" is in the first line, textllm will generate a title for the document (using the same model). This can be disabled or just manually set the title. To regenerate a title, reset the title to `!!AUTO TITLE!!`.
71
+
72
+ If `--rename` is set, the document will also be renamed for the title. Numbers will be added to the name as needed to avoid conflicts if needed.
73
+
74
+ ## Environment Variables
75
+
76
+ Most behavior is governed by command-line flags but there are a few exceptions.
77
+
78
+ | Variable | Description |
79
+ |--|--|
80
+ |`$TEXTLLM_ENV_PATH` | Path to an environment file for API keys. |
81
+ |`$TEXTLLM_AUTO_RENAME` | Set to "true" to make `--rename` the *default*. Command-line settings will override. |
82
+
83
+ ## Format Description
84
+
85
+ The format is designed to be very simple. An input is broken up into three main parts
86
+
87
+ 1. Title (optional).
88
+ 2. Settings (optional)
89
+ 3. Conversation
90
+
91
+ ### (1) Title:
92
+
93
+ First line of the document. If and only if it contains "!!AUTO TITLE!!", it will be replaced with an appropriate title based on the document (using the LLM).
94
+
95
+ Generally, this is only set once but "!!AUTO TITLE!!" is added back to the first line, it will get refreshed
96
+
97
+ ### (2) Settings
98
+
99
+ Optionally specify settings for the object in [TOML][toml] format inside of a Markdown fenced code block. Do NOT modify the leading comments as they are needed for the correct parseing. All settings are directly passed including 'model'. Model should be in the format of "<provider>:<name>" format where providers are those from LangChain. See [`init_chat_model` docs][init_chat_model] for the naming scheme and needed Python package and [Chat Models][chat models] for more details.
100
+
101
+ Note that they require an API key. It can be specified in the settings or can be set with an environment variable. Alternatively, an environment file can be specified with '$TEXTLLM_ENV_PATH' that may contain all API keys
102
+
103
+ ### (3) Conversation
104
+
105
+ The conversation is with a simple format. There are three block types as demonstrated below. General practice is to specify 'system' at the top and only once but textllm will translate all that are specified.
106
+
107
+ ```text
108
+ --- System ---
109
+
110
+ Enter your system prompt. These are like *super* user blocks.
111
+
112
+ --- User ---
113
+
114
+ The last "User" block is usually the question.
115
+
116
+ --- Assistant ---
117
+
118
+ The response.
119
+ ```
120
+
121
+ Generally, you want the final block to be the new "User" question but it doesn't have to be. Note that a new "--- User ---" heading will be added after the last response.
122
+
123
+ You can escape a block with a leading "\". It will be done if somehow the response also has such a block.
124
+
125
+
126
+ [toml]: https://toml.io/
127
+ [init_chat_model]: https://python.langchain.com/api_reference/langchain/chat_models/langchain.chat_models.base.init_chat_model.html
128
+ [chat models]: https://python.langchain.com/docs/integrations/chat/
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ setup.py
3
+ textllm.py
4
+ textllm.egg-info/PKG-INFO
5
+ textllm.egg-info/SOURCES.txt
6
+ textllm.egg-info/dependency_links.txt
7
+ textllm.egg-info/entry_points.txt
8
+ textllm.egg-info/requires.txt
9
+ textllm.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ textllm = textllm:cli
@@ -0,0 +1,2 @@
1
+ python-dotenv
2
+ langchain
@@ -0,0 +1 @@
1
+ textllm
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import tomllib
5
+ import re
6
+ import itertools
7
+ import os, sys
8
+ import logging
9
+ import json
10
+ import argparse
11
+ from pathlib import Path
12
+ from textwrap import dedent
13
+ from functools import cached_property
14
+ import shutil
15
+
16
+ from dotenv import load_dotenv # pip install python-dotenv
17
+
18
+ from langchain.chat_models import init_chat_model
19
+ from langchain_core.messages import (
20
+ AIMessage,
21
+ HumanMessage,
22
+ SystemMessage,
23
+ merge_message_runs,
24
+ )
25
+
26
+ __version__ = "20250201.0.0"
27
+
28
+ log = logging.getLogger("textllm")
29
+
30
+ TEXTLLM_ENV_PATH = os.environ.get("TEXTLLM_ENV_PATH", None)
31
+ TEXTLLM_AUTO_RENAME = os.environ.get("TEXTLLM_AUTO_RENAME", "").lower() == "true"
32
+
33
+
34
+ AUTO_TITLE = "!!AUTO TITLE!!"
35
+ TEMPLATE = f"""\
36
+ # {AUTO_TITLE}
37
+
38
+ ```toml
39
+ # Optional Settings
40
+ # TOML Format
41
+ model = "openai:gpt-4o"
42
+ # model = "openai:gpt-4o-mini"
43
+ # model = "anthropic:claude-3-5-haiku-20241022"
44
+ temperature = 0.5
45
+
46
+ # END Optional Settings
47
+ ```
48
+
49
+ --- System ---
50
+
51
+ You are a helpful assistant. Provide clear and thorough answers but be concise unless instructed otherwise.
52
+
53
+ --- User ---
54
+
55
+ """
56
+
57
+ TITLE_SYSTEM_PROMPT = """\
58
+ Provide an appropriate, consice, title for this conversation. The conversation is in JSON form with roles 'system' (or 'developer'), 'human', and 'ai'.
59
+
60
+ - Aim for fewer than 5 words but absolutely no more than 10.
61
+ - Give more influence to earlier messages than later.
62
+ - Be as concise as possible without losing the context of the conversation.
63
+ - Your goal is to extract the key point of the conversation
64
+ - Make sure the title is also appropriate for a filename. Spaces are acceptable.
65
+ - Reply with ONLY the title and nothing else!
66
+ """
67
+
68
+ MAX_FILENAME_CHAR = 240
69
+
70
+ flag2role = {
71
+ "--- system ---": SystemMessage,
72
+ "--- user ---": HumanMessage,
73
+ "--- assistant ---": AIMessage,
74
+ }
75
+
76
+ RETURN_AFTER_CLI_FOR_DEVEL = False
77
+
78
+
79
+ class Conversation:
80
+ def __init__(self, filepath):
81
+
82
+ if load_dotenv(TEXTLLM_ENV_PATH):
83
+ # $TEXTLLM_ENV_PATH defaults to None to look in the parent dir.
84
+ log.debug(f"Loaded env. ${TEXTLLM_ENV_PATH = }")
85
+ else:
86
+ log.debug(f"Could not load env. ${TEXTLLM_ENV_PATH = }")
87
+
88
+ self.filepath = filepath
89
+
90
+ # Read and truncate file. Do it now in case the title is updated
91
+ with open(self.filepath, "rb+") as fp:
92
+ content = fp.read().rstrip()
93
+ self.text = content.decode("UTF-8")
94
+
95
+ fp.seek(len(content), 0)
96
+ fp.truncate()
97
+
98
+ self.messages = self.read_conversation()
99
+
100
+ def call_llm(self, messages, **new_settings):
101
+ settings = self.settings.copy() | new_settings
102
+ log.debug(f"Settings {settings}")
103
+
104
+ model = settings.pop("model") # Will KeyError if not set as expected
105
+ try:
106
+ model_provider, model_name = model.split(":", 1)
107
+ except ValueError:
108
+ model_provider = None
109
+ model_name = model
110
+ log.debug(f"{model!r} does not contain a provider. Will try to infer")
111
+
112
+ log.debug(f"{model_provider = } {model_name = }")
113
+
114
+ chat_model = init_chat_model(
115
+ model=model_name,
116
+ model_provider=model_provider,
117
+ **settings,
118
+ )
119
+
120
+ response = chat_model.invoke(messages)
121
+
122
+ try:
123
+ logtxt = (
124
+ f"tokens: "
125
+ f"prompt {response.usage_metadata['input_tokens']}, "
126
+ f"completion {response.usage_metadata['output_tokens']}, "
127
+ f"total {response.usage_metadata['total_tokens']}"
128
+ )
129
+ log.debug(logtxt)
130
+ except:
131
+ # The above seems to only work well with OpenAI.
132
+ # ToDO: Fix this
133
+ pass
134
+
135
+ return response
136
+
137
+ def chat(self, require_user_prompt=True):
138
+ if require_user_prompt and (
139
+ not self.messages or not isinstance(self.messages[-1], HumanMessage)
140
+ ):
141
+ raise NoHumanMessageError("Must have a new user message")
142
+
143
+ response = self.call_llm(messages=self.messages)
144
+
145
+ # Not really needed but in case I do more with it later
146
+ self.messages.append(response)
147
+
148
+ # Add escapes to the content
149
+ content = response.content
150
+ pattern = re.compile(
151
+ "(" + "|".join("^" + re.escape(flag) for flag in flag2role) + ")",
152
+ flags=re.DOTALL | re.MULTILINE | re.IGNORECASE,
153
+ )
154
+ content = pattern.sub(r"\\\1", content)
155
+
156
+ with open(self.filepath, "at") as fp:
157
+ fp.write("\n\n--- Assistant ---\n\n")
158
+ fp.write(content)
159
+ fp.write("\n\n--- User ---\n\n")
160
+
161
+ log.info(f"Updated {self.filepath!r}")
162
+
163
+ def set_title(self):
164
+ top, rest = self.text.split("\n", 1)
165
+ if AUTO_TITLE not in top:
166
+ log.debug(f"{AUTO_TITLE!r} not found in first line.")
167
+ return # This will happen nearly every time but the first
168
+
169
+ messages = [(m.type, m.content) for m in self.messages]
170
+ new = [
171
+ SystemMessage(content=TITLE_SYSTEM_PROMPT),
172
+ HumanMessage(content=json.dumps(messages)),
173
+ ]
174
+
175
+ response = self.call_llm(messages=new, temperature=0.1)
176
+ title = response.content
177
+
178
+ top = top.replace(AUTO_TITLE, title)
179
+ self.text = f"{top}\n{rest}"
180
+ with open(self.filepath, "wt") as fp:
181
+ fp.write(self.text)
182
+ log.info(f"Set title to {title!r}")
183
+
184
+ @cached_property
185
+ def settings(self):
186
+ defaults = Conversation.read_settings(TEMPLATE)
187
+ new = Conversation.read_settings(self.text)
188
+ final = defaults | new
189
+ return final
190
+
191
+ @staticmethod
192
+ def read_settings(text):
193
+
194
+ pattern = re.compile(
195
+ r"```toml\s*"
196
+ r"# Optional Settings\s*"
197
+ r"(.*?)"
198
+ r"^# END Optional Settings\s*"
199
+ r"```",
200
+ flags=re.DOTALL | re.MULTILINE,
201
+ )
202
+ match = pattern.search(text)
203
+ if match:
204
+ toml_content = match.group(1).strip()
205
+ return tomllib.loads(toml_content) # Parse as TOML
206
+
207
+ return {}
208
+
209
+ def read_conversation(self):
210
+ conversation = []
211
+
212
+ pattern = re.compile(
213
+ "(" + "|".join("^" + re.escape(flag) for flag in flag2role) + ")",
214
+ flags=re.DOTALL | re.MULTILINE | re.IGNORECASE,
215
+ )
216
+
217
+ split_text = pattern.split(self.text)
218
+
219
+ # Decide if the first item is a flag. It likely isn't but could be!
220
+ if split_text[0].lower() not in flag2role:
221
+ del split_text[0]
222
+
223
+ for flag, msg in grouper(split_text, 2):
224
+ msg = msg.strip()
225
+ if not msg:
226
+ continue # Empty or blank
227
+
228
+ # Clean up and unescape
229
+ msg_lines = []
230
+ for line in msg.strip().split("\n"):
231
+ if any(line.lower().startswith(rf"\{flag}") for flag in flag2role):
232
+ line = line[1:]
233
+ msg_lines.append(line)
234
+
235
+ conversation.append(flag2role[flag.lower()](content="\n".join(msg_lines)))
236
+
237
+ return merge_message_runs(conversation)
238
+
239
+ def rename_by_title(self):
240
+ dirname = os.path.dirname(self.filepath)
241
+
242
+ # Clean the current for possible "<name> (n).<ext>"
243
+ base, ext = os.path.splitext(self.filepath)
244
+ cleaned_filepath = re.sub(r" \(\d+\)$", "", base) + ext
245
+ cleaned_filename = os.path.basename(cleaned_filepath)
246
+ log.debug(f"{cleaned_filename = }")
247
+
248
+ # Compute the new name without worrying about duplicates
249
+ title, *_ = self.text.split("\n", 1)
250
+
251
+ if AUTO_TITLE in title: # BEFORE cleaning it
252
+ log.warning(f"{AUTO_TITLE!r} in title. Not renaming!")
253
+ return
254
+
255
+ # Sub unsafe or invalid characters
256
+ invalid_chars = set(
257
+ "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13"
258
+ '\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"*/:<>?\\|'
259
+ )
260
+ title = title.strip().strip("#").strip()
261
+ title_based_filebase = "".join(c for c in title if c not in invalid_chars)
262
+ title_based_filebase = title_based_filebase[: (MAX_FILENAME_CHAR - len(ext))]
263
+ title_based_filename = title_based_filebase + ext
264
+ title_based_filepath = os.path.join(dirname, title_based_filename)
265
+ log.debug(f"{title_based_filename = }")
266
+ if cleaned_filename == title_based_filename:
267
+ log.debug("Already named by title")
268
+
269
+ # Ensure it is unique by added " (n)" up to 99
270
+ c = 0
271
+ while os.path.exists(title_based_filepath):
272
+ c += 1
273
+ if c >= 100:
274
+ raise ValueError(f"Too many for {title_based_filebase + ext!r}")
275
+
276
+ new = f"{title_based_filebase} ({c}){ext}"
277
+ title_based_filepath = os.path.join(dirname, new)
278
+ log.debug(f"Required {c} iterations for unique name")
279
+
280
+ shutil.move(self.filepath, title_based_filepath)
281
+ log.info(f"Rename by title {self.filepath!r} --> {title_based_filepath!r}")
282
+ self.filepath = title_based_filepath
283
+
284
+
285
+ def grouper(iterable, n, *, fillvalue=None):
286
+ iterators = [iter(iterable)] * n
287
+ return itertools.zip_longest(*iterators, fillvalue="")
288
+
289
+
290
+ class NoHumanMessageError(ValueError):
291
+ """Error when a conversation doesn't end with a HumanMessage"""
292
+
293
+
294
+ def cli(argv=None):
295
+
296
+ parser = argparse.ArgumentParser(
297
+ description="Simple LLM interface that reads and writes to a text file",
298
+ epilog="See readme.md for details on format description",
299
+ # formatter_class=argparse.RawDescriptionHelpFormatter,
300
+ )
301
+
302
+ parser.add_argument(
303
+ "conversation",
304
+ help="""
305
+ Input file in the noted format. If it does not exists, the template will
306
+ instead be written there.
307
+ """,
308
+ )
309
+
310
+ parser.add_argument(
311
+ "--title",
312
+ choices=["auto", "only", "off"],
313
+ default="auto",
314
+ help=f"""
315
+ [%(default)s] How to set the title. If 'auto', will replace {AUTO_TITLE!r}
316
+ with the generated title. If 'only', will only replace the title and
317
+ not continue the chat. If 'off', will not update the title (or rename).
318
+ The title is the first line.
319
+ """,
320
+ )
321
+
322
+ parser.add_argument(
323
+ "--u", # To make --no-u an easy option
324
+ "--require-user-prompt",
325
+ dest="require_user_prompt",
326
+ action=argparse.BooleanOptionalAction,
327
+ default=True,
328
+ help="""
329
+ Whether or not to require there be a user prompt at the end of
330
+ the messages. Default %(default)s
331
+ """,
332
+ )
333
+
334
+ parser.add_argument(
335
+ "--rename",
336
+ action=argparse.BooleanOptionalAction,
337
+ default=TEXTLLM_AUTO_RENAME,
338
+ help=f"""
339
+ Rename the file based on the title. The title must not have {AUTO_TITLE!r}
340
+ in the title. Will increment the file if one already exists. Default is based
341
+ on environment variable whether $TEXTLLM_AUTO_RENAME == "true". Currently %(default)s
342
+ """,
343
+ )
344
+
345
+ parser.add_argument(
346
+ "--version",
347
+ action="version",
348
+ version="%(prog)s-" + __version__,
349
+ )
350
+
351
+ verb = parser.add_argument_group("Verbosity Settings:")
352
+ verb.add_argument(
353
+ "-s", "--silent", action="count", default=0, help="Decrease Verbosity"
354
+ )
355
+ verb.add_argument(
356
+ "-v", "--verbose", action="count", default=0, help="Increase Verbosity"
357
+ )
358
+
359
+ args = parser.parse_args(argv)
360
+
361
+ # Define logging levels
362
+ levels = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
363
+ level_index = args.verbose - args.silent + 2 # +1: WARNING, +2: INFO
364
+ level_index = max(0, min(level_index, len(levels) - 1)) # Always keep ERROR
365
+
366
+ log.setLevel(levels[level_index])
367
+
368
+ console_handler = logging.StreamHandler()
369
+ fmt = logging.Formatter(
370
+ "%(asctime)s:%(levelname)s: %(message)s",
371
+ datefmt="%Y-%m-%d %H:%M:%S",
372
+ )
373
+ console_handler.setFormatter(fmt)
374
+ log.addHandler(console_handler)
375
+
376
+ log.debug(f"argv: {sys.argv[1:]}")
377
+ log.debug(f"{args = }")
378
+
379
+ filepath = args.conversation
380
+
381
+ try:
382
+ if not os.path.exists(filepath):
383
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
384
+ with open(filepath, "xt") as fp:
385
+ fp.write(TEMPLATE)
386
+ log.info(f"{filepath!r} does not exist. Created template.")
387
+ sys.exit()
388
+ else:
389
+ log.debug(f"{filepath!r} exists")
390
+
391
+ convo = Conversation(filepath)
392
+
393
+ if args.title != "off":
394
+ convo.set_title() # Will do nothing if AUTO_TITLE not in the top line
395
+ if args.title == "only":
396
+ return convo
397
+
398
+ convo.chat(require_user_prompt=args.require_user_prompt)
399
+
400
+ if args.rename:
401
+ convo.rename_by_title()
402
+
403
+ if RETURN_AFTER_CLI_FOR_DEVEL:
404
+ return convo
405
+
406
+ except Exception as E:
407
+ log.error(E)
408
+ if levels[level_index] == logging.DEBUG:
409
+ raise
410
+
411
+
412
+ if __name__ == "__main__":
413
+ cli()