├── wren ├── __init__.py ├── http_server.py ├── cli.py ├── telegram.py ├── matrix.py └── core.py ├── .gitignore ├── LICENSE ├── pyproject.toml └── README.md /wren/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env3 2 | __pycache__ 3 | *.egg-info/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yo'av Moshe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wren-tools" 3 | version = "0.5.0" 4 | description = "The simplest task management system with the most advanced features" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "croniter==2.0.1", 9 | "pathvalidate==3.2.0", 10 | "platformdirs==4.1.0", 11 | "python-dateutil==2.8.2", 12 | "pytz==2023.3.post1", 13 | ] 14 | classifiers = ["License :: OSI Approved :: MIT License"] 15 | keywords = ["note taking", "todo", "to do", "task management"] 16 | 17 | [project.optional-dependencies] 18 | telegram = [ 19 | "telebot==0.0.5", 20 | "tzlocal==5.2", 21 | "idna==3.6", 22 | "APScheduler==3.10.4", 23 | "pyTelegramBotAPI==4.14.1", 24 | "certifi==2023.11.17 ", 25 | "charset-normalizer==3.3.2", 26 | ] 27 | http = ["bottle==0.12.25"] 28 | matrix = ["APScheduler==3.10.4", "simplematrixbotlib==2.10.3"] 29 | llm = ["litellm==1.66.3"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/bjesus/wren" 33 | 34 | [project.scripts] 35 | wren = "wren.cli:main" 36 | 37 | [build-system] 38 | requires = ["setuptools"] 39 | build-backend = "setuptools.build_meta" 40 | 41 | [tool.setuptools] 42 | license-files = [] 43 | -------------------------------------------------------------------------------- /wren/http_server.py: -------------------------------------------------------------------------------- 1 | try: 2 | from bottle import route, run, request, abort, auth_basic 3 | except: 4 | print("Please install the HTTP server dependencies: pip install 'wren-notes[http]'") 5 | exit(1) 6 | from wren.core import ( 7 | create_new_task, 8 | mark_task_done, 9 | get_tasks, 10 | get_task_content, 11 | config, 12 | ) 13 | from json import dumps 14 | 15 | back = "
back" 16 | create_form = "

" 17 | 18 | 19 | def is_request_html(request): 20 | headers = request.headers 21 | return "html" in headers["Accept"] 22 | 23 | 24 | def is_authenticated_user(user, password): 25 | if config["http_user"] == user and config["http_password"] == password: 26 | return True 27 | else: 28 | return False 29 | 30 | 31 | def auth(func): 32 | if not config["http_user"] and not config["http_password"]: 33 | return func 34 | return auth_basic(is_authenticated_user)(func) 35 | 36 | 37 | @route("/", method="GET") 38 | @auth 39 | def query(): 40 | tasks = get_tasks() 41 | if is_request_html(request): 42 | return ( 43 | "" 51 | + create_form 52 | ) 53 | return {"tasks": get_tasks()} 54 | 55 | 56 | @route("/", method="POST") 57 | @auth 58 | def create(): 59 | filename = "" 60 | value = "" 61 | if request.json: 62 | value = request.json["task"] 63 | else: 64 | value = request.forms.get("task") 65 | if request.body: 66 | filename = create_new_task(value) 67 | if is_request_html(request): 68 | if filename: 69 | return f"

task created successfully: {filename}

" + back 70 | else: 71 | abort(400, "

couldn't create empty task

") 72 | if filename: 73 | return {"task": filename} 74 | else: 75 | abort(400, dumps({"error": "task is empty"})) 76 | 77 | 78 | @route("/", method="GET") 79 | @auth 80 | def read_content(task): 81 | content = get_task_content(task) 82 | if is_request_html(request): 83 | return "
" + content + "
" + back 84 | return {"content": content} 85 | 86 | 87 | @route("/", method="DELETE") 88 | @auth 89 | def done(task): 90 | response = mark_task_done(task) 91 | if is_request_html(request): 92 | return "

" + response + "

" + back 93 | return {"result": response} 94 | 95 | 96 | def start_server(): 97 | run(host="localhost", port=8080) 98 | -------------------------------------------------------------------------------- /wren/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import argparse 5 | import subprocess 6 | from random import choice 7 | from wren.core import ( 8 | create_new_task, 9 | get_summary, 10 | get_task_content, 11 | get_task_file, 12 | get_tasks, 13 | mark_task_done, 14 | prepend_to_filename, 15 | notes_dir, 16 | config_file, 17 | data_dir, 18 | __version__, 19 | ) 20 | 21 | editor = os.environ.get("EDITOR", "vi") 22 | 23 | 24 | def create_file(name): 25 | filename = create_new_task(name) 26 | print("created task:", filename) 27 | 28 | 29 | def list_files(s="", done=False): 30 | tasks = get_tasks(s, done) 31 | print("".join(map(lambda t: "➜ " + t + "\n", tasks))[:-1]) 32 | 33 | 34 | def print_random(): 35 | tasks = get_tasks("") 36 | print(choice(tasks)) 37 | 38 | 39 | def print_summary(): 40 | summary = get_summary() 41 | print(summary) 42 | 43 | 44 | def edit_content(name): 45 | found, filename = get_task_file(name) 46 | if found: 47 | filepath = os.path.join(notes_dir, filename) 48 | subprocess.run([editor, filepath]) 49 | 50 | 51 | def read_content(name): 52 | content = get_task_content(name) 53 | print(content) 54 | 55 | 56 | def prepend_to_task(name, text): 57 | content = prepend_to_filename(name, text) 58 | print(content) 59 | 60 | 61 | def mark_done(name): 62 | message = mark_task_done(name) 63 | print(message) 64 | 65 | 66 | def main(): 67 | parser = argparse.ArgumentParser() 68 | parser.add_argument("task", nargs="*", help="a new task to be created") 69 | parser.add_argument( 70 | "-l", 71 | "--ls", 72 | "--list", 73 | type=str, 74 | help="List all current tasks. add -d to list done tasks", 75 | nargs="?", 76 | const="", 77 | default=None, 78 | ) 79 | parser.add_argument( 80 | "-d", 81 | "--done", 82 | metavar="foo", 83 | type=str, 84 | nargs="?", 85 | const="", 86 | help="Mark a task as done", 87 | ) 88 | parser.add_argument( 89 | "-r", "--read", metavar="foo", type=str, help="Read a task content" 90 | ) 91 | parser.add_argument( 92 | "-e", "--edit", metavar="foo", type=str, help="Edit a task content" 93 | ) 94 | parser.add_argument( 95 | "--prepend", 96 | metavar="foo", 97 | type=str, 98 | help="Prepend something to the task's filename", 99 | ) 100 | 101 | parser.add_argument( 102 | "-o", "--one", action="store_true", help="Print one random task" 103 | ) 104 | parser.add_argument( 105 | "-s", "--summary", action="store_true", help="Generate a summary" 106 | ) 107 | parser.add_argument("--telegram", action="store_true", help="Start Telegram bot") 108 | parser.add_argument("--matrix", action="store_true", help="Start Matrix bot") 109 | parser.add_argument("--http", action="store_true", help="Start HTTP server") 110 | parser.add_argument("--version", action="store_true", help="Show Wren version") 111 | 112 | args = parser.parse_args() 113 | 114 | if args.ls != None: 115 | list_files(args.ls, args.done != None) 116 | elif args.version: 117 | print("Wren " + __version__) 118 | print("\nconfig: " + config_file) 119 | print("data directory: " + data_dir) 120 | elif args.http: 121 | from wren.http_server import start_server 122 | 123 | start_server() 124 | elif args.telegram: 125 | from wren.telegram import start_bot 126 | 127 | start_bot() 128 | elif args.matrix: 129 | from wren.matrix import start_bot 130 | 131 | start_bot() 132 | elif args.one: 133 | print_random() 134 | elif args.edit: 135 | edit_content(args.edit) 136 | elif args.prepend: 137 | prepend_to_task(" ".join(args.task), args.prepend) 138 | elif args.summary: 139 | print_summary() 140 | elif args.read: 141 | read_content(args.read) 142 | elif args.done: 143 | mark_done(args.done) 144 | else: 145 | if args.task: 146 | create_file(" ".join(args.task)) 147 | else: 148 | list_files("", args.done != None) 149 | 150 | 151 | if __name__ == "__main__": 152 | main() 153 | -------------------------------------------------------------------------------- /wren/telegram.py: -------------------------------------------------------------------------------- 1 | try: 2 | import telebot 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.triggers.cron import CronTrigger 5 | except: 6 | print( 7 | "Please install the Telegram bot dependencies: pip install 'wren-notes[telegram]'" 8 | ) 9 | exit(1) 10 | import os 11 | import json 12 | from platformdirs import user_data_dir 13 | from croniter import croniter 14 | from wren.core import ( 15 | create_new_task, 16 | get_summary, 17 | mark_task_done, 18 | get_tasks, 19 | get_task_content, 20 | config, 21 | ) 22 | 23 | bot = telebot.TeleBot(config["telegram_token"]) 24 | allowed_chats = config["allowed_telegram_chats"] 25 | scheduler = BackgroundScheduler() 26 | 27 | data_dir = user_data_dir("wren", "wren") 28 | schedules_path = os.path.join(data_dir, "schedules.json") 29 | 30 | 31 | def only_allowed_chats(func): 32 | def authenticate(*args, **kwargs): 33 | chat_id = args[0].chat.id 34 | if chat_id not in allowed_chats: 35 | bot.send_message( 36 | chat_id, 37 | "please add this chat id to your allowed chats: " + str(chat_id), 38 | ) 39 | return 40 | result = func(*args, **kwargs) 41 | return result 42 | 43 | return authenticate 44 | 45 | 46 | def get_all_schedules(): 47 | try: 48 | with open(schedules_path, "r") as file: 49 | return json.load(file) 50 | except FileNotFoundError: 51 | return [] 52 | 53 | 54 | @bot.message_handler(commands=["list"]) 55 | @only_allowed_chats 56 | def list_tasks(message): 57 | filter = " ".join(message.text.split(" ")[1:]) 58 | tasks = get_tasks(filter) 59 | response = "".join(map(lambda t: "- " + t + "\n", tasks)) 60 | bot.send_message(message.chat.id, response) 61 | 62 | 63 | @bot.message_handler(commands=["summary"]) 64 | @only_allowed_chats 65 | def summary(message): 66 | summary = get_summary() 67 | bot.send_message(message.chat.id, summary) 68 | 69 | 70 | @bot.message_handler(commands=["done", "d", "do", "don"]) 71 | @only_allowed_chats 72 | def mark_as_done(message): 73 | name = " ".join(message.text.split(" ")[1:]) 74 | response = mark_task_done(name) 75 | bot.send_message(message.chat.id, response) 76 | 77 | 78 | @bot.message_handler(commands=["read"]) 79 | @only_allowed_chats 80 | def read_task(message): 81 | name = " ".join(message.text.split(" ")[1:]) 82 | response = get_task_content(name) 83 | bot.send_message(message.chat.id, response) 84 | 85 | 86 | @bot.message_handler(commands=["help"]) 87 | @only_allowed_chats 88 | def help(message): 89 | bot.send_message( 90 | message.chat.id, 91 | "wren\n\n- enter any text to save it as task\n- mark a task as done by `/done foo`", 92 | ) 93 | 94 | 95 | @bot.message_handler(commands=["start"]) 96 | def start(message): 97 | bot.set_my_commands( 98 | [ 99 | telebot.types.BotCommand(command="help", description="Help"), 100 | telebot.types.BotCommand(command="summary", description="Summary"), 101 | telebot.types.BotCommand(command="list", description="List"), 102 | telebot.types.BotCommand(command="schedule", description="Schedule"), 103 | ] 104 | ) 105 | bot.set_chat_menu_button( 106 | message.chat.id, telebot.types.MenuButtonCommands("commands") 107 | ) 108 | bot.send_message(message.chat.id, "wren.\n\nuse /help to get help") 109 | 110 | 111 | @bot.message_handler(commands=["schedule"]) 112 | @only_allowed_chats 113 | def create_scheduled_message(message): 114 | schedule = " ".join(message.text.split(" ")[1:]) 115 | job = [message.chat.id, schedule] 116 | 117 | # read current schedules 118 | schedules = get_all_schedules() 119 | 120 | if not schedule: 121 | my_schedules = [s[1] for s in schedules if s[0] == message.chat.id] 122 | if my_schedules: 123 | bot.send_message( 124 | message.chat.id, 125 | f'your summaries are scheduled for: {", ".join(my_schedules)}', 126 | ) 127 | else: 128 | bot.send_message(message.chat.id, f"you got nothing scheduled!") 129 | return 130 | 131 | if croniter.is_valid(schedule): 132 | # save new schedule 133 | schedules.extend([job]) 134 | with open(schedules_path, "w") as file: 135 | json.dump(schedules, file, indent=2) 136 | 137 | # register it 138 | scheduler.add_job( 139 | send_summary, 140 | CronTrigger.from_crontab(schedule), 141 | kwargs={"chat_id": message.chat.id}, 142 | ) 143 | bot.send_message(message.chat.id, f"Added a schedule: {schedule}") 144 | else: 145 | bot.send_message(message.chat.id, f"Invalid cron format: {schedule}") 146 | 147 | 148 | @bot.message_handler( 149 | func=lambda message: len(message.text) > 3 and not message.text.startswith("/") 150 | ) 151 | @only_allowed_chats 152 | def add(message): 153 | filename = create_new_task(message.text) 154 | bot.send_message(message.chat.id, "added: " + filename) 155 | 156 | 157 | @bot.message_handler(func=lambda message: True) 158 | @only_allowed_chats 159 | def reply_no(message): 160 | bot.reply_to(message, "not sure what you want. seek /help") 161 | 162 | 163 | def send_summary(chat_id): 164 | bot.send_message(chat_id, get_summary()) 165 | 166 | 167 | schedules = get_all_schedules() 168 | for schedule in schedules: 169 | scheduler.add_job( 170 | send_summary, 171 | CronTrigger.from_crontab(schedule[1]), 172 | kwargs={"chat_id": schedule[0]}, 173 | ) 174 | 175 | 176 | def start_bot(): 177 | print("Starting telegram bot") 178 | scheduler.start() 179 | bot.infinity_polling(timeout=10, long_polling_timeout=5) 180 | -------------------------------------------------------------------------------- /wren/matrix.py: -------------------------------------------------------------------------------- 1 | try: 2 | import simplematrixbotlib as botlib 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.triggers.cron import CronTrigger 5 | except: 6 | print( 7 | "Please install the Matrix bot dependencies: pip install 'wren-notes[matrix]'" 8 | ) 9 | exit(1) 10 | import os 11 | import json 12 | from croniter import croniter 13 | from platformdirs import user_data_dir 14 | from wren.core import ( 15 | create_new_task, 16 | get_summary, 17 | mark_task_done, 18 | get_tasks, 19 | get_task_content, 20 | config, 21 | ) 22 | 23 | creds = botlib.Creds( 24 | config["matrix_homeserver"], config["matrix_localpart"], config["matrix_password"] 25 | ) 26 | bot = botlib.Bot(creds) 27 | PREFIX = "!" 28 | COMMANDS = ["list", "summary", "done", "done", "do", "d", "read", "help", "schedule"] 29 | scheduler = BackgroundScheduler() 30 | 31 | data_dir = user_data_dir("wren", "wren") 32 | schedules_path = os.path.join(data_dir, "schedules.json") 33 | 34 | 35 | def get_all_schedules(): 36 | try: 37 | with open(schedules_path, "r") as file: 38 | return json.load(file) 39 | except FileNotFoundError: 40 | return [] 41 | 42 | 43 | @bot.listener.on_message_event 44 | async def list_tasks(room, message): 45 | match = botlib.MessageMatch(room, message, bot, PREFIX) 46 | body = message.body 47 | filter = " ".join(body.split(" ")[1:]) 48 | tasks = get_tasks(filter) 49 | if len(tasks) > 0: 50 | response = "".join(map(lambda t: "- " + t + "\n", tasks)) 51 | else: 52 | response = "no current tasks\nenter any text to save it as task" 53 | if match.prefix() and match.command("list") and match.is_not_from_this_bot(): 54 | await bot.api.send_text_message(room.room_id, response) 55 | 56 | 57 | @bot.listener.on_message_event 58 | async def summary(room, message): 59 | match = botlib.MessageMatch(room, message, bot, PREFIX) 60 | summary = get_summary() 61 | if match.prefix() and match.command("summary"): 62 | await bot.api.send_text_message(room.room_id, summary) 63 | 64 | 65 | @bot.listener.on_message_event 66 | async def mark_as_done(room, message): 67 | match = botlib.MessageMatch(room, message, bot, PREFIX) 68 | if ( 69 | match.prefix() 70 | and match.command("done") 71 | or match.command("don") 72 | or match.command("do") 73 | or match.command("d") 74 | and match.is_not_from_this_bot() 75 | ): 76 | text = message.body 77 | name = " ".join(text.split(" ")[1:]) 78 | response = mark_task_done(name) 79 | await bot.api.send_text_message(room.room_id, response) 80 | 81 | 82 | @bot.listener.on_message_event 83 | async def read_task(room, message): 84 | match = botlib.MessageMatch(room, message, bot, PREFIX) 85 | if match.prefix() and match.command("read") and match.is_not_from_this_bot(): 86 | text = message.body 87 | name = " ".join(text.split(" ")[1:]) 88 | response = get_task_content(name) 89 | await bot.api.send_text_message(room.room_id, response) 90 | 91 | 92 | @bot.listener.on_message_event 93 | async def help(room, message): 94 | match = botlib.MessageMatch(room, message, bot, PREFIX) 95 | if match.prefix() and match.command("help") and match.is_not_from_this_bot(): 96 | await bot.api.send_text_message( 97 | room.room_id, 98 | "wren\n\n- enter any text to save it as task\n- mark a task as done by `/done foo`", 99 | ) 100 | 101 | 102 | @bot.listener.on_message_event 103 | async def create_scheduled_message(room, message): 104 | match = botlib.MessageMatch(room, message, bot, PREFIX) 105 | 106 | if match.prefix() and match.command("schedule") and match.is_not_from_this_bot(): 107 | text = message.body 108 | schedule = " ".join(text.split(" ")[1:6]) 109 | task = " ".join(text.split(" ")[6:]) 110 | job = [room.room_id, schedule, task] 111 | 112 | # read current schedules 113 | schedules = get_all_schedules() 114 | nl = "\n* " 115 | if not schedule: 116 | my_schedules = [ 117 | f"{s[2]} at {s[1]}" for s in schedules if s[0] == room.room_id 118 | ] 119 | if my_schedules: 120 | await bot.api.send_text_message( 121 | room.room_id, 122 | f"your scheduled summaries are:{nl}{nl.join(my_schedules)}", 123 | ) 124 | else: 125 | await bot.api.send_text_message( 126 | room.room_id, f"you got nothing scheduled!" 127 | ) 128 | return 129 | 130 | if croniter.is_valid(schedule): 131 | # save new schedule 132 | schedules.extend([job]) 133 | with open(schedules_path, "w") as file: 134 | json.dump(schedules, file, indent=2) 135 | 136 | # register schedule 137 | scheduler.add_job( 138 | send_summary, 139 | CronTrigger.from_crontab(schedule), 140 | kwargs={"room": room.room_id}, 141 | ) 142 | await bot.api.send_text_message( 143 | room.room_id, f"Added a schedule: {job[1]}: {job[2]}" 144 | ) 145 | else: 146 | await bot.api.send_text_message( 147 | room.room_id, f"Invalid cron format: {schedule}" 148 | ) 149 | 150 | 151 | @bot.listener.on_message_event 152 | async def add(room, message): 153 | match = botlib.MessageMatch(room, message, bot, PREFIX) 154 | if not match.prefix() and match.is_not_from_this_bot(): 155 | text = message.body 156 | filename = create_new_task(text) 157 | await bot.api.send_text_message(room.room_id, "added: " + filename) 158 | 159 | 160 | @bot.listener.on_message_event 161 | async def reply_no(room, message): 162 | match = botlib.MessageMatch(room, message, bot, PREFIX) 163 | command = message.body.split()[0] 164 | if match.prefix() and match.is_not_from_this_bot() and command[1:] not in COMMANDS: 165 | await bot.api.send_text_message( 166 | room.room_id, 167 | "not sure what you want. seek !help" + " entered: " + message.body, 168 | ) 169 | 170 | 171 | async def send_summary(room): 172 | await bot.api.send_text_message(room.room_id, get_summary()) 173 | 174 | 175 | schedules = get_all_schedules() 176 | for schedule in schedules: 177 | print("scheduled task: " + schedule[2] + " at " + schedule[1]) 178 | scheduler.add_job( 179 | send_summary, 180 | CronTrigger.from_crontab(schedule[1]), 181 | kwargs={"room": schedule[0]}, 182 | ) 183 | 184 | 185 | def start_bot(): 186 | print("Starting matrix bot") 187 | scheduler.start() 188 | bot.run() 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Wren 3 |

4 | 5 |

6 | a note taking application and a to-do management system that is ridiculously simple, yet very advanced. 7 |

8 |

9 | 10 |

11 | 12 | Wren is simple because every note is one file. The filename is the title, and the content is the note's content. This makes it very easy to sync tasks between devices, as conflicts can almost never happen, even if syncing isn't done real time. The files are plain text, so you can just write. If you want a task to repeat every Saturday you just prefix it with a cron syntax, e.g. `0 8 * * 6 weekly swim`, and if you want a task to appear from a specific time you just start it with the date, like `2030-01-01 check if Wren became super popular`. 13 | 14 | Wren is advanced because it is very extensible - it comes (optionally!) with a Telegram bot that you can chat with to manage your notes, a Matrix bot, and even get AI-driven daily summaries as if you had a personal assistant. It also includes a tiny HTTP server that you can use to manage tasks using an API or from the browser, which can be used for displaying you tasks elsewhere (e.g. in your e-reader). 15 | 16 | https://github.com/bjesus/wren/assets/55081/0deff819-ab30-4a64-a4db-5e9a29179309 17 | 18 | ## Installation 19 | 20 | The easiest way to install Wren is with `pip` or `uv`: 21 | 22 | ``` 23 | $ pip install wren-notes 24 | $ uv tool install wren-tools 25 | ``` 26 | 27 | To install with all optional dependencies: 28 | ``` 29 | $ pip install "wren-notes[telegram,http,llm]" 30 | $ uv tool install "wren-notes[telegram,http,llm]" 31 | ``` 32 | 33 | ## Usage 34 | 35 | The management of tasks in Wren is simple: 36 | - Tasks are just files living your `notes` folder. You might as well create them with `touch task` and edit them with `vim task`. 37 | - Completed tasks are moved to your `done` folder. 38 | - Tasks starting with a YYYY-MM-DD will not appear in the list of tasks before their time arrived. 39 | - Tasks starting with a cron signature will not be moved when completed. Instead they'll be copied to the `done` directory, and will reappear automatically when the copied file is old enough. 40 | 41 | ### Command line 42 | 43 | The regular usage mode Wren is the command line. For the following examples, `n` is my alias to `wren`, but you can use any alias or just call `wren` directly. Normal tasks can be created by just typing them 44 | ``` 45 | $ n build a spaceship 46 | created task: build a spaceship 47 | 48 | $ n go to the moon 49 | created task: go to the moon 50 | 51 | $ n 'discuss galaxy peace with aliens 52 | tell them that we won't hurt them 53 | and that we can probably reach some agreement' 54 | created task: discuss galaxy peace with aliens 55 | ``` 56 | 57 | Reading a task content is done with the `-r` flag: 58 | ``` 59 | $ n -r galaxy 60 | discuss galaxy peace with aliens 61 | tell them that we won't hurt them 62 | and that we can probably reach some agreement 63 | ``` 64 | Note that when referring to a task, you can give Wren any part of the task title. 65 | 66 | For listing your current tasks, just run `n`. Or if you want to filter your tasks, you can use `n --ls query`: 67 | ``` 68 | $ n 69 | ➜ discuss galaxy peace with aliens 70 | ➜ go to the moon 71 | ➜ build a spaceship 72 | 73 | $ n --ls th 74 | ➜ discuss galaxy peace with aliens 75 | ➜ go to the moon 76 | ``` 77 | 78 | Use `-e` to edit a task in your `$EDITOR` or `-d` to mark it as done: 79 | ``` 80 | $ n -d moon 81 | marked "go to the moon" as done 82 | ``` 83 | 84 | Check out everything you've done by using `-d` without a value: 85 | ``` 86 | $ n -d 87 | ➜ go to the moon 88 | ``` 89 | 90 | If you want to postpone a task, just prepend some timestamp to it: 91 | ``` 92 | $ n --prepend 2030-01-01 galaxy 93 | renamed "discuss galaxy peace with aliens" to "2030-01-01 discuss galaxy peace with aliens" 94 | ``` 95 | 96 | ### Integrations 97 | 98 | ##### Random task 99 | Use `--one` to print one random task. I'm using it with [Waybar](https://github.com/Alexays/Waybar/) to always have one task displayed at the bottom of my screen, like this: 100 | ``` 101 | "custom/task": { 102 | "tooltip": true, 103 | "max-length": 20, 104 | "interval": 60, 105 | "exec": "wren --one" 106 | }, 107 | ``` 108 | 109 | ##### AI Assistant 110 | 111 | Wren can also work like an AI Assistant. If you use `--summary` it will use [LiteLLM](https://github.com/BerriAI/litellm) to reach the LLM model of your choice and create a nice, human like message, telling you what's waiting for you today, and congratulating you for the stuff you have completed recently. You can use it to update `/etc/motd` daily, or through the Telegram bot (below). 112 | 113 | ##### Telegram bot 114 | 115 | Using `--telegram` will spin up a Telegram bot listener that will respond to your messages and allow you to create tasks, list them, edit them and so on. It will also allow you to set a cron-based schedule for receiving AI Assistant messages. This can be handy if you want to start your day with a message from Wren telling you about your upcoming tasks. 116 | 117 | - List tasks using `/list` 118 | - Create task by just writing it, e.g. `make a plan for going back to earth` 119 | - Mark as done with `/done plan` 120 | - See more at `/help` 121 | 122 | If you want to run it outside your computer (e.g. so it's always available), I highly recommend using [Syncthing](https://syncthing.net/) to sync your notes. 123 | 124 | ##### HTTP Server 125 | 126 | With `--http` you get both a simple tiny website that works through the browser, and an API server that accepts and returns JSON. Either browse to `http://localhost:8080` or send requests directly with the proper headers: 127 | - List tasks: `curl http://localhost:8080` 128 | - Create task: `curl http://localhost:8080 -d '{"task": "create HTTP interface"}' -H 'content-type: application/json'` 129 | - Mark as done: `curl http://localhost:8080/content -X DELETE` 130 | 131 | The HTTP server can be used to integrate with voice assistants, [Home Assistant](https://www.home-assistant.io/), [Tasker](https://joaoapps.com/tasker/) etc. Like with the Telegram bot, if you want to run it outside your computer, I recommend using [Syncthing](https://syncthing.net/). 132 | 133 | ##### Matrix bot 134 | 135 | Using `--matrix` will spin up a Matrix bot that works very similarly to the Telegram bot. 136 | 137 | - List tasks using `!list` 138 | - Create task by just writing it, e.g. `make a plan for going back to earth` 139 | - Mark as done with `!done plan` 140 | - See more at `!help` 141 | 142 | ## Configuration 143 | 144 | See the configuration path on your operating system using `--version`. 145 | 146 | The schema is as follows and **all keys are optional**. You can disable HTTP/Telegram/Matrix/AI features by simply not including them in your config. Remove the comments from your actual file. 147 | ``` 148 | { 149 | "notes_dir": "~/Notes", // This can absolute or include ~ 150 | "done_dir": "done", // This can be relative to the notes dir, or absolute 151 | "http_user": "", // Enable basic HTTP auth for the HTTP server 152 | "http_password": "", // Password for HTTP basic auth 153 | "llm_model": "", // LLM model name, in a litellm syntax. e.g. "gemini/gemini-2.5-flash-preview-04-17" or "openai/gpt-4o" 154 | "llm_key": "", // LLM access token. Alternatively set it through an env variable 155 | "telegram_token": "", // Token for the Telegram bot 156 | "matrix_homeserver": "", // Settings for the Matrix bot 157 | "matrix_localpart": "", // 158 | "matrix_password": "", // 159 | "allowed_telegram_chats": [ 160 | 1234564868 // Initiating a chat will print out the chat ID you should fill here 161 | ], 162 | // Below you can put context to give the AI assistant 163 | "about_user": "I work at NASA as Product Manager. Mars is the name of my dog." 164 | } 165 | ``` 166 | -------------------------------------------------------------------------------- /wren/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathvalidate import sanitize_filename as sanitize_filename_orig 4 | import json 5 | from datetime import datetime 6 | from dateutil import parser 7 | from platformdirs import user_data_dir, user_config_dir 8 | from croniter import croniter 9 | 10 | __version__ = "0.5.0" 11 | 12 | # Load config and set up folders 13 | 14 | data_dir = user_data_dir("wren", "wren") 15 | config_dir = user_config_dir("wren", "wren") 16 | messages_log = os.path.join(data_dir, "messages.json") 17 | 18 | config = { 19 | "notes_dir": "~/Notes", 20 | "done_dir": "~/Notes/done", 21 | "http_user": "", 22 | "http_password": "", 23 | "openai_token": "", 24 | "telegram_token": "", 25 | "allowed_telegram_chats": [], 26 | "about_user": "The user chose to specify nothing.", 27 | "matrix_homeserver": "", 28 | "matrix_localpart": "", 29 | "matrix_password": "", 30 | } 31 | 32 | config_file = os.path.join(config_dir, "wren.json") 33 | 34 | try: 35 | with open(config_file, "r") as file: 36 | user_config = json.load(file) 37 | except FileNotFoundError: 38 | user_config = {} 39 | config = {**config, **user_config} 40 | 41 | 42 | def parse_path(p, base=""): 43 | return os.path.join(base, os.path.expanduser(p)) 44 | 45 | 46 | notes_dir = parse_path(config["notes_dir"]) 47 | done_dir = parse_path(config["done_dir"], notes_dir) 48 | 49 | now = datetime.now() 50 | 51 | 52 | def mkdir(path: str) -> None: 53 | if not os.path.exists(path): 54 | os.makedirs(path) 55 | 56 | 57 | mkdir(data_dir) 58 | mkdir(notes_dir) 59 | mkdir(done_dir) 60 | 61 | 62 | # Common API 63 | 64 | 65 | def create_new_task(content: str) -> str: 66 | filename = sanitize_filename(content) 67 | content = "\n".join(content.split("\n")[1:]) 68 | with open(os.path.join(notes_dir, filename), "w") as file: 69 | file.write(content) 70 | return filename 71 | 72 | 73 | def get_tasks(query="", done=False) -> list[str]: 74 | global now 75 | now = datetime.now() 76 | files_dir = notes_dir if not done else done_dir 77 | return [ 78 | format_task_name(file) 79 | for file in sorted( 80 | os.listdir(files_dir), 81 | key=lambda x: os.path.getctime(os.path.join(files_dir, x)), 82 | reverse=True, 83 | ) 84 | if os.path.isfile(os.path.join(files_dir, file)) 85 | and not file.startswith(".") 86 | and query in file 87 | and is_present_task(file) 88 | ] 89 | 90 | 91 | def get_summary() -> str: 92 | from litellm import completion 93 | 94 | current_time = datetime.now().isoformat() 95 | tasks = get_tasks() 96 | current_message = {"role": "user", "content": f"{current_time}\n{tasks}"} 97 | 98 | try: 99 | with open(messages_log, "r") as file: 100 | existing_data = json.load(file) 101 | except FileNotFoundError: 102 | existing_data = [] 103 | 104 | response = completion( 105 | model=config["llm_model"], 106 | messages=[ 107 | { 108 | "role": "system", 109 | "content": "You are a helpful assistant that helps the user be on top of their schedule and tasks. every once in a while, the user is going to send you the current time and a list of currently pending tasks. your role is to tell the user in a simple language what they need to do today. IF AND ONLY IF a task has been ongoing for a long time, let the user know about it. IF AND ONLY IF you see a task that appeared earlier in the chat but doesn't appear anymore, add a small congratulation to acknowledge the fact that the task was completed. the user will send each task in a new line starting with a dash. words starting with a plus sign are tags related to task. when writing back to the user, try to mention tasks that share the same tags or concept together and be concise. The user added the following context: " 110 | + config["about_user"], 111 | }, 112 | ] 113 | + existing_data 114 | + [current_message], 115 | api_key=config.get("llm_key"), 116 | ) 117 | 118 | data = response.json() 119 | response = data["choices"][0]["message"]["content"] 120 | 121 | existing_data.extend([current_message, data["choices"][0]["message"]]) 122 | 123 | with open(messages_log, "w") as file: 124 | json.dump(existing_data, file, indent=2) 125 | 126 | return response 127 | 128 | 129 | def get_task_file(name: str) -> tuple[bool, str]: 130 | matching_files = [ 131 | file 132 | for file in os.listdir(notes_dir) 133 | if name.lower() in file.lower() 134 | and os.path.isfile(os.path.join(notes_dir, file)) 135 | ] 136 | if len(matching_files) == 1: 137 | return (True, matching_files[0]) 138 | elif len(matching_files) > 1: 139 | return ( 140 | False, 141 | "Error - multiple matching files found:\n- " + "\n- ".join(matching_files), 142 | ) 143 | else: 144 | return (False, f"Error: No matching file for '{name}' found.") 145 | 146 | 147 | def mark_task_done(name: str) -> str: 148 | found, filename = get_task_file(name) 149 | if found: 150 | if is_cron_task(filename): 151 | shutil.copy( 152 | os.path.join(notes_dir, filename), os.path.join(done_dir, filename) 153 | ) 154 | else: 155 | shutil.move( 156 | os.path.join(notes_dir, filename), os.path.join(done_dir, filename) 157 | ) 158 | response = f'marked "{filename}" as done' 159 | else: 160 | response = filename 161 | return response 162 | 163 | 164 | def prepend_to_filename(name: str, prepend_text: str) -> str: 165 | found, filename = get_task_file(name) 166 | if found: 167 | new_filename = sanitize_filename(prepend_text + " " + filename) 168 | shutil.move( 169 | os.path.join(notes_dir, filename), 170 | os.path.join(notes_dir, new_filename), 171 | ) 172 | response = f'renamed "{filename}" to "{new_filename}"' 173 | else: 174 | response = filename 175 | return response 176 | 177 | 178 | def get_task_content(name: str) -> str: 179 | found, filename = get_task_file(name) 180 | if found: 181 | file_to_read = os.path.join(notes_dir, filename) 182 | with open(file_to_read, "r") as file: 183 | file_content = file.read() 184 | response = f"{filename}\n\n{file_content}" 185 | else: 186 | response = filename 187 | return response 188 | 189 | 190 | # Helper functions 191 | 192 | 193 | def is_present_task(file: str) -> bool: 194 | if not file[0].isdigit(): 195 | return True 196 | if is_cron_task(file): 197 | cron = " ".join(file.replace("*", "*").split(" ")[:5]) 198 | last_task = None 199 | path = os.path.join(done_dir, file) 200 | if os.path.exists(path): 201 | last_modified_date = datetime.fromtimestamp(os.path.getmtime(path)) 202 | if last_task is None or last_modified_date > last_task: 203 | last_task = last_modified_date 204 | if not last_task or croniter(cron, last_task).get_next(datetime) <= now: 205 | return True 206 | else: 207 | return True 208 | elif is_dated_task(file): 209 | time = file.split(" ")[0] 210 | task_time = parser.parse(time) 211 | if task_time <= now: 212 | return True 213 | return False 214 | 215 | 216 | def format_task_name(filename: str) -> str: 217 | if is_cron_task(filename): 218 | return " ".join(filename.split()[5:]) 219 | if is_dated_task(filename): 220 | return " ".join(filename.split()[1:]) 221 | return filename 222 | 223 | 224 | def is_dated_task(filename: str) -> bool: 225 | try: 226 | parser.parse(filename.split()[0]) 227 | return True 228 | except: 229 | return False 230 | 231 | 232 | def is_cron_task(filename: str) -> bool: 233 | splitted = filename.split() 234 | if len(splitted) < 6 or not all( 235 | (s.isdigit() or s in ["*", "*"]) for s in splitted[:3] 236 | ): 237 | return False 238 | return True 239 | 240 | 241 | def sanitize_filename(filename: str) -> str: 242 | return sanitize_filename_orig(filename.split("\n")[0].replace("*", "*")) 243 | --------------------------------------------------------------------------------