jasem 0.2.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.
- jasem-0.2.0.dist-info/METADATA +178 -0
- jasem-0.2.0.dist-info/RECORD +7 -0
- jasem-0.2.0.dist-info/WHEEL +5 -0
- jasem-0.2.0.dist-info/entry_points.txt +2 -0
- jasem-0.2.0.dist-info/licenses/LICENSE +21 -0
- jasem-0.2.0.dist-info/top_level.txt +1 -0
- jasem.py +911 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jasem
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A plain-text task manager and time tracker with pluggable AI parsing (Ollama / OpenAI-compatible / Anthropic).
|
|
5
|
+
Author-email: mrfatolahi1 <fatolahi.cs@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mrfatolahi1/Jasem
|
|
8
|
+
Project-URL: Source, https://github.com/mrfatolahi1/Jasem
|
|
9
|
+
Keywords: todo,tasks,cli,time-tracking,ollama,llm,anthropic,openai
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# jasem
|
|
20
|
+
|
|
21
|
+
A plain-text **task manager** and **time tracker** for your terminal.
|
|
22
|
+
|
|
23
|
+
You write tasks in natural language; an AI backend extracts the structure
|
|
24
|
+
(title, deadline, priority, tags) and plain Python turns the deadline phrase
|
|
25
|
+
into a real date. Everything is stored in a human-readable Markdown table, so
|
|
26
|
+
you can read, grep, edit, sync, or version-control it however you like.
|
|
27
|
+
|
|
28
|
+
- **Zero dependencies.** A single Python file using only the standard library.
|
|
29
|
+
- **Local by default.** Works fully offline with [Ollama](https://ollama.com).
|
|
30
|
+
- **Bring your own AI.** Point it at any OpenAI-compatible API or at Anthropic
|
|
31
|
+
with two environment variables — no code changes.
|
|
32
|
+
- **Degrades gracefully.** If no model is reachable, your task is still saved
|
|
33
|
+
(dates are parsed with regex; you just don't get auto title/priority/tags).
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
$ jasem "pay rent next friday, high priority, finance"
|
|
37
|
+
✓ added #1: pay rent
|
|
38
|
+
priority=high deadline=2026-06-19 tags=finance
|
|
39
|
+
|
|
40
|
+
$ jasem today
|
|
41
|
+
Due today
|
|
42
|
+
☐ 2 [medium] 2026-06-15 (today) call dentist #health
|
|
43
|
+
|
|
44
|
+
$ jasem track "1h 30min, code review, work"
|
|
45
|
+
✓ tracked 1h 30min · code review · today · #work
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
jasem needs **Python 3.8+** and nothing else.
|
|
51
|
+
|
|
52
|
+
### With pipx (recommended)
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pipx install .
|
|
56
|
+
# or straight from a checkout / git URL once you publish it:
|
|
57
|
+
# pipx install git+https://github.com/your-username/jasem
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### With pip
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pip install .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Both install a `jasem` command on your `PATH`.
|
|
67
|
+
|
|
68
|
+
### No install at all
|
|
69
|
+
|
|
70
|
+
It's one file — copy it and run it:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
cp jasem.py ~/.local/bin/jasem && chmod +x ~/.local/bin/jasem
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Choosing an AI backend
|
|
77
|
+
|
|
78
|
+
Natural-language parsing is the only step that uses a model. Pick a backend
|
|
79
|
+
with `JASEM_PROVIDER`; the rest is config via environment variables.
|
|
80
|
+
|
|
81
|
+
### Ollama (default — local, free, private)
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
ollama serve
|
|
85
|
+
ollama pull qwen2.5:3b # or any model you like
|
|
86
|
+
jasem "submit report by friday, work"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
No keys, nothing leaves your machine. Override the model with
|
|
90
|
+
`JASEM_MODEL=qwen2.5:7b` and the host with `OLLAMA_HOST`.
|
|
91
|
+
|
|
92
|
+
### Any OpenAI-compatible API (OpenAI, Groq, OpenRouter, Together, LM Studio, vLLM, …)
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
export JASEM_PROVIDER=openai
|
|
96
|
+
export JASEM_API_KEY=sk-...
|
|
97
|
+
export JASEM_MODEL=gpt-4o-mini # default for this provider
|
|
98
|
+
# For a non-OpenAI host, also set the base URL:
|
|
99
|
+
export JASEM_API_BASE=https://api.groq.com/openai/v1
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Anthropic (Claude)
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
export JASEM_PROVIDER=anthropic
|
|
106
|
+
export JASEM_API_KEY=sk-ant-...
|
|
107
|
+
export JASEM_MODEL=claude-opus-4-8 # default; e.g. claude-haiku-4-5 for cheaper/faster
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Put whichever block you use in your shell profile (`~/.zshrc`, `~/.bashrc`) so
|
|
111
|
+
it's set for every session.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
ADD
|
|
117
|
+
jasem "pay rent next friday, high priority, finance"
|
|
118
|
+
jasem add "..." force-add even if the text starts with a command word
|
|
119
|
+
|
|
120
|
+
VIEW (append a category to filter, e.g. jasem list work)
|
|
121
|
+
jasem list (ls) open tasks, soonest deadline first
|
|
122
|
+
jasem today due today
|
|
123
|
+
jasem week due within the next 7 days
|
|
124
|
+
jasem overdue past deadline, not done
|
|
125
|
+
jasem all everything, including completed
|
|
126
|
+
jasem tags list categories in use, with counts
|
|
127
|
+
|
|
128
|
+
UPDATE
|
|
129
|
+
jasem done <id>... mark task(s) complete
|
|
130
|
+
jasem rm <id>... delete task(s)
|
|
131
|
+
jasem set <id> priority high | medium | low
|
|
132
|
+
jasem set <id> deadline next friday | in 3 days | 2026-07-01 | none
|
|
133
|
+
jasem set <id> category work finance (space/comma-separated; "none" clears)
|
|
134
|
+
|
|
135
|
+
TIME TRACKING format: "<time>, <work>[, <date>][, <tag>]"
|
|
136
|
+
jasem track "2h, coding"
|
|
137
|
+
jasem track "30 min, coding, yesterday, work"
|
|
138
|
+
jasem track today's entries, with a daily total
|
|
139
|
+
jasem track week last 7 days, grouped by day
|
|
140
|
+
jasem track all [tag] everything; optional tag filter
|
|
141
|
+
|
|
142
|
+
jasem help full, colorized help
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Run `jasem help` for the complete reference.
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
| Env var | Default | Purpose |
|
|
150
|
+
|--------------------|---------------------------------|------------------------------------------------------|
|
|
151
|
+
| `JASEM_PROVIDER` | `ollama` | `ollama` \| `openai` \| `anthropic` |
|
|
152
|
+
| `JASEM_MODEL` | per provider | Model id |
|
|
153
|
+
| `JASEM_API_KEY` | — | Key for openai/anthropic (also reads `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) |
|
|
154
|
+
| `JASEM_API_BASE` | provider default | Base URL for any OpenAI-compatible / custom endpoint |
|
|
155
|
+
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama daemon address |
|
|
156
|
+
| `JASEM_DIR` | `~/.jasem` | Where data is stored |
|
|
157
|
+
| `JASEM_FILE` | `$JASEM_DIR/tasks.md` | Tasks file |
|
|
158
|
+
| `JASEM_TRACK_FILE` | `$JASEM_DIR/timelog.md` | Time-log file |
|
|
159
|
+
|
|
160
|
+
## How storage works
|
|
161
|
+
|
|
162
|
+
`tasks.md` and `timelog.md` are ordinary Markdown tables. You can hand-edit
|
|
163
|
+
rows, keep them in a synced folder, or commit them to a private repo — just
|
|
164
|
+
keep the column order intact. jasem reads and rewrites the whole file on each
|
|
165
|
+
write, preserving your edits.
|
|
166
|
+
|
|
167
|
+
## Writing good tasks
|
|
168
|
+
|
|
169
|
+
Clearer cues parse better:
|
|
170
|
+
|
|
171
|
+
- **deadline:** `tomorrow`, `next friday`, `in 3 days`, `june 20`, `2026-07-01` (avoid `asap`/`soon`)
|
|
172
|
+
- **priority:** say `high` or `low` (default `medium`; avoid `urgent`/`important`)
|
|
173
|
+
- **category:** name it plainly — `work`, `finance`, `university`
|
|
174
|
+
- **pattern:** `"<what> by <when>, <priority>, <category>"`
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
jasem.py,sha256=6BlKEV2X1XKgv09DMJg1r-93udHt9d0ISteoQs21-BQ,33663
|
|
2
|
+
jasem-0.2.0.dist-info/licenses/LICENSE,sha256=fHbTfpkuDq63oFe_LHPHDYRkiuBNmwODJUols9Hbc1Y,1068
|
|
3
|
+
jasem-0.2.0.dist-info/METADATA,sha256=kDv2rxVi3usu5bU66GyO7iL2X9931pKRQGT1oiIrUmA,6589
|
|
4
|
+
jasem-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
jasem-0.2.0.dist-info/entry_points.txt,sha256=T9rVf_hEhlxcfG3sOWk0KFCkwpSoBeCO1torCxyIjlE,36
|
|
6
|
+
jasem-0.2.0.dist-info/top_level.txt,sha256=OfN7emyb2SWKK4rpGTI5g6lHt0gVDbG_RdBNhIL-8oY,6
|
|
7
|
+
jasem-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mrfatolahi1
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jasem
|
jasem.py
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""jasem — a plain-text task manager and time tracker.
|
|
3
|
+
|
|
4
|
+
Add tasks in natural language; an AI backend extracts the structure and plain
|
|
5
|
+
Python resolves the deadline to a real date. Storage is a human-readable
|
|
6
|
+
Markdown table. By default everything runs locally (Ollama); you can point it
|
|
7
|
+
at any OpenAI-compatible API or at Anthropic instead. If no AI is reachable,
|
|
8
|
+
tasks are still saved (with regex-based date parsing).
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
jasem <free text> Add a task, e.g. jasem pay rent next friday, high, finance
|
|
12
|
+
jasem add <free text> Same as above (explicit)
|
|
13
|
+
jasem list | ls Show open tasks, soonest deadline first
|
|
14
|
+
jasem today Tasks due today
|
|
15
|
+
jasem overdue Tasks past their deadline (not done)
|
|
16
|
+
jasem week Tasks due within the next 7 days
|
|
17
|
+
jasem done <id> [id...] Mark task(s) complete
|
|
18
|
+
jasem set <id> <field> <value> Change priority, deadline, or category
|
|
19
|
+
jasem rm <id> [id...] Delete task(s)
|
|
20
|
+
jasem all Show everything, including completed
|
|
21
|
+
jasem tags List categories in use, with counts
|
|
22
|
+
jasem track "<time>, <work>[, <date>][, <tag>]"
|
|
23
|
+
Log time spent; date blank = today, tag blank = work
|
|
24
|
+
jasem track [today|week|all] [tag]
|
|
25
|
+
Show the time log grouped by day, with totals
|
|
26
|
+
jasem help This help
|
|
27
|
+
|
|
28
|
+
AI parsing (the only step that uses a model):
|
|
29
|
+
JASEM_PROVIDER ollama (default) | openai | anthropic
|
|
30
|
+
JASEM_MODEL model id (default per provider)
|
|
31
|
+
JASEM_API_KEY API key for openai/anthropic (falls back to
|
|
32
|
+
OPENAI_API_KEY / ANTHROPIC_API_KEY)
|
|
33
|
+
JASEM_API_BASE base URL override (any OpenAI-compatible endpoint;
|
|
34
|
+
default https://api.openai.com/v1 or https://api.anthropic.com)
|
|
35
|
+
OLLAMA_HOST default http://localhost:11434
|
|
36
|
+
|
|
37
|
+
Storage (plain Markdown, hand-editable):
|
|
38
|
+
JASEM_DIR default ~/.jasem
|
|
39
|
+
JASEM_FILE default $JASEM_DIR/tasks.md
|
|
40
|
+
JASEM_TRACK_FILE default $JASEM_DIR/timelog.md
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
import re
|
|
46
|
+
import json
|
|
47
|
+
import calendar
|
|
48
|
+
import datetime as dt
|
|
49
|
+
import urllib.request
|
|
50
|
+
import urllib.error
|
|
51
|
+
|
|
52
|
+
# ---------- AI backend configuration ----------
|
|
53
|
+
PROVIDER = os.environ.get("JASEM_PROVIDER", "ollama").strip().lower()
|
|
54
|
+
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
55
|
+
API_BASE = os.environ.get("JASEM_API_BASE", "").rstrip("/")
|
|
56
|
+
API_KEY = (
|
|
57
|
+
os.environ.get("JASEM_API_KEY")
|
|
58
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
59
|
+
or os.environ.get("ANTHROPIC_API_KEY")
|
|
60
|
+
or ""
|
|
61
|
+
)
|
|
62
|
+
_DEFAULT_MODEL = {
|
|
63
|
+
"ollama": "qwen2.5:3b",
|
|
64
|
+
"openai": "gpt-4o-mini",
|
|
65
|
+
"anthropic": "claude-opus-4-8",
|
|
66
|
+
}
|
|
67
|
+
MODEL = os.environ.get("JASEM_MODEL") or _DEFAULT_MODEL.get(PROVIDER, "qwen2.5:3b")
|
|
68
|
+
|
|
69
|
+
# ---------- storage configuration ----------
|
|
70
|
+
JASEM_DIR = os.path.expanduser(os.environ.get("JASEM_DIR", "~/.jasem"))
|
|
71
|
+
TASK_FILE = os.path.expanduser(
|
|
72
|
+
os.environ.get("JASEM_FILE", os.path.join(JASEM_DIR, "tasks.md"))
|
|
73
|
+
)
|
|
74
|
+
TRACK_FILE = os.path.expanduser(
|
|
75
|
+
os.environ.get("JASEM_TRACK_FILE", os.path.join(JASEM_DIR, "timelog.md"))
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
COLS = ["ID", "✓", "Priority", "Deadline", "Task", "Tags", "Created"]
|
|
79
|
+
TRACK_COLS = ["Date", "Time", "Work", "Tag"]
|
|
80
|
+
PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2}
|
|
81
|
+
|
|
82
|
+
# ---------- ANSI colors (only when writing to a real terminal) ----------
|
|
83
|
+
_TTY = sys.stdout.isatty()
|
|
84
|
+
def _c(code, s):
|
|
85
|
+
return f"\033[{code}m{s}\033[0m" if _TTY else s
|
|
86
|
+
RED = lambda s: _c("31", s)
|
|
87
|
+
YELLOW = lambda s: _c("33", s)
|
|
88
|
+
GREEN = lambda s: _c("32", s)
|
|
89
|
+
DIM = lambda s: _c("2", s)
|
|
90
|
+
BOLD = lambda s: _c("1", s)
|
|
91
|
+
CYAN = lambda s: _c("36", s)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ===================== date resolution (pure Python) =====================
|
|
95
|
+
WEEKDAYS = {n.lower(): i for i, n in enumerate(calendar.day_name)}
|
|
96
|
+
WEEKDAYS.update({n.lower(): i for i, n in enumerate(calendar.day_abbr)})
|
|
97
|
+
MONTHS = {n.lower(): i for i, n in enumerate(calendar.month_name) if n}
|
|
98
|
+
MONTHS.update({n.lower(): i for i, n in enumerate(calendar.month_abbr) if n})
|
|
99
|
+
|
|
100
|
+
NO_DATE = {"", "none", "no deadline", "no date", "n/a", "na", "null", "someday", "-"}
|
|
101
|
+
|
|
102
|
+
# Words that mean "empty this field" when editing a task.
|
|
103
|
+
CLEAR_WORDS = {"none", "no", "clear", "remove", "-", "n/a", "na", "null", ""}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _add_months(d, n):
|
|
107
|
+
m = d.month - 1 + n
|
|
108
|
+
y = d.year + m // 12
|
|
109
|
+
m = m % 12 + 1
|
|
110
|
+
return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _next_weekday(today, target, include_today=False):
|
|
114
|
+
days = (target - today.weekday()) % 7
|
|
115
|
+
if days == 0 and not include_today:
|
|
116
|
+
days = 7
|
|
117
|
+
return today + dt.timedelta(days=days)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_date(phrase, today, llm_date=""):
|
|
121
|
+
"""Turn a temporal phrase into YYYY-MM-DD, or '' for no deadline."""
|
|
122
|
+
def fallback():
|
|
123
|
+
d = (llm_date or "").strip()
|
|
124
|
+
return d if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d) else ""
|
|
125
|
+
|
|
126
|
+
p = (phrase or "").strip().lower()
|
|
127
|
+
if p in NO_DATE:
|
|
128
|
+
return fallback()
|
|
129
|
+
|
|
130
|
+
# explicit ISO date anywhere in the phrase
|
|
131
|
+
m = re.search(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b", p)
|
|
132
|
+
if m:
|
|
133
|
+
try:
|
|
134
|
+
return dt.date(int(m[1]), int(m[2]), int(m[3])).isoformat()
|
|
135
|
+
except ValueError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# "in N day(s)/week(s)/month(s)"
|
|
139
|
+
m = re.search(r"\bin\s+(\d+)\s+(day|week|month)s?\b", p)
|
|
140
|
+
if m:
|
|
141
|
+
n, unit = int(m[1]), m[2]
|
|
142
|
+
if unit == "day":
|
|
143
|
+
return (today + dt.timedelta(days=n)).isoformat()
|
|
144
|
+
if unit == "week":
|
|
145
|
+
return (today + dt.timedelta(days=7 * n)).isoformat()
|
|
146
|
+
return _add_months(today, n).isoformat()
|
|
147
|
+
|
|
148
|
+
# "next <weekday>" -> always strictly in the future
|
|
149
|
+
m = re.search(r"\bnext\s+(\w+)", p)
|
|
150
|
+
if m and m[1] in WEEKDAYS:
|
|
151
|
+
return _next_weekday(today, WEEKDAYS[m[1]], include_today=False).isoformat()
|
|
152
|
+
|
|
153
|
+
# relative day words (match whether input is a clean phrase or a full sentence)
|
|
154
|
+
if re.search(r"\btomorrow\b", p):
|
|
155
|
+
return (today + dt.timedelta(days=1)).isoformat()
|
|
156
|
+
if re.search(r"\byesterday\b", p):
|
|
157
|
+
return (today - dt.timedelta(days=1)).isoformat()
|
|
158
|
+
if re.search(r"\b(today|tonight|now)\b", p):
|
|
159
|
+
return today.isoformat()
|
|
160
|
+
if re.search(r"\bnext\s+week\b", p):
|
|
161
|
+
return (today + dt.timedelta(days=7)).isoformat()
|
|
162
|
+
if re.search(r"\bnext\s+month\b", p):
|
|
163
|
+
return _add_months(today, 1).isoformat()
|
|
164
|
+
if re.search(r"\b(eow|end of week|this week)\b", p):
|
|
165
|
+
return _next_weekday(today, 4, include_today=True).isoformat() # Friday
|
|
166
|
+
|
|
167
|
+
# "this <weekday>" or a bare weekday -> next occurrence (today counts)
|
|
168
|
+
for name, idx in WEEKDAYS.items():
|
|
169
|
+
if re.search(rf"\b{re.escape(name)}\b", p):
|
|
170
|
+
return _next_weekday(today, idx, include_today=True).isoformat()
|
|
171
|
+
|
|
172
|
+
# "<month> <day>[ , year]"
|
|
173
|
+
m = re.search(r"([a-z]+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", p)
|
|
174
|
+
if m and m[1] in MONTHS:
|
|
175
|
+
year = int(m[3]) if m[3] else today.year
|
|
176
|
+
try:
|
|
177
|
+
d = dt.date(year, MONTHS[m[1]], int(m[2]))
|
|
178
|
+
if not m[3] and d < today:
|
|
179
|
+
d = dt.date(year + 1, MONTHS[m[1]], int(m[2]))
|
|
180
|
+
return d.isoformat()
|
|
181
|
+
except ValueError:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# "<day> <month>[ , year]"
|
|
185
|
+
m = re.search(r"(\d{1,2})\s+([a-z]+)(?:,?\s*(\d{4}))?", p)
|
|
186
|
+
if m and m[2] in MONTHS:
|
|
187
|
+
year = int(m[3]) if m[3] else today.year
|
|
188
|
+
try:
|
|
189
|
+
d = dt.date(year, MONTHS[m[2]], int(m[1]))
|
|
190
|
+
if not m[3] and d < today:
|
|
191
|
+
d = dt.date(year + 1, MONTHS[m[2]], int(m[1]))
|
|
192
|
+
return d.isoformat()
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
return fallback()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ===================== AI parsing (pluggable backend) =====================
|
|
200
|
+
SCHEMA = {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"properties": {
|
|
203
|
+
"title": {"type": "string"},
|
|
204
|
+
"deadline_phrase": {"type": "string"},
|
|
205
|
+
"deadline_date": {"type": "string"},
|
|
206
|
+
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
|
|
207
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
208
|
+
},
|
|
209
|
+
"required": ["title", "deadline_phrase", "deadline_date", "priority", "tags"],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_JSON_SYS = (
|
|
213
|
+
"You output only a single JSON object with the requested fields. "
|
|
214
|
+
"No prose, no markdown, no code fences."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_prompt(text, today):
|
|
219
|
+
rules = (
|
|
220
|
+
"Extract structured fields from the task description below.\n"
|
|
221
|
+
"- title: short imperative summary, WITHOUT any date/priority/tag words.\n"
|
|
222
|
+
"- deadline_phrase: the exact temporal words as written, for example "
|
|
223
|
+
"next thursday / tomorrow / june 20 / in 3 days; empty string if none.\n"
|
|
224
|
+
"- deadline_date: your best YYYY-MM-DD guess for the deadline, empty string if none.\n"
|
|
225
|
+
"- priority: low, medium, or high (default medium).\n"
|
|
226
|
+
"- tags: short topic words mentioned such as work, finance, personal; "
|
|
227
|
+
"empty list if none.\n"
|
|
228
|
+
"Always capture any time words (tomorrow, friday, next week, by june 20) "
|
|
229
|
+
"in deadline_phrase, even when they follow words like 'by' or 'due'.\n"
|
|
230
|
+
)
|
|
231
|
+
examples = (
|
|
232
|
+
'Example. Task: "review Ali PR by tomorrow, work" -> '
|
|
233
|
+
'{"title": "review Ali PR", "deadline_phrase": "tomorrow", '
|
|
234
|
+
'"deadline_date": "", "priority": "medium", "tags": ["work"]}\n'
|
|
235
|
+
'Example. Task: "pay rent next friday high priority" -> '
|
|
236
|
+
'{"title": "pay rent", "deadline_phrase": "next friday", '
|
|
237
|
+
'"deadline_date": "", "priority": "high", "tags": []}\n'
|
|
238
|
+
)
|
|
239
|
+
return (
|
|
240
|
+
f"Today is {today.isoformat()} ({today.strftime('%A')}).\n"
|
|
241
|
+
f"{rules}\n{examples}\nTask: {text}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _http_json(url, payload, headers, timeout=120):
|
|
246
|
+
req = urllib.request.Request(
|
|
247
|
+
url,
|
|
248
|
+
data=json.dumps(payload).encode(),
|
|
249
|
+
headers={"Content-Type": "application/json", **headers},
|
|
250
|
+
)
|
|
251
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
252
|
+
return json.load(r)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _extract_json(text):
|
|
256
|
+
"""Pull a JSON object out of a model reply (tolerates code fences / prose)."""
|
|
257
|
+
text = (text or "").strip()
|
|
258
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
259
|
+
if m:
|
|
260
|
+
text = m.group(1)
|
|
261
|
+
try:
|
|
262
|
+
return json.loads(text)
|
|
263
|
+
except ValueError:
|
|
264
|
+
start = text.find("{")
|
|
265
|
+
if start < 0:
|
|
266
|
+
raise
|
|
267
|
+
depth = 0
|
|
268
|
+
for i in range(start, len(text)):
|
|
269
|
+
if text[i] == "{":
|
|
270
|
+
depth += 1
|
|
271
|
+
elif text[i] == "}":
|
|
272
|
+
depth -= 1
|
|
273
|
+
if depth == 0:
|
|
274
|
+
return json.loads(text[start:i + 1])
|
|
275
|
+
raise
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_ollama(prompt):
|
|
279
|
+
payload = {
|
|
280
|
+
"model": MODEL,
|
|
281
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
282
|
+
"stream": False,
|
|
283
|
+
"format": SCHEMA, # Ollama constrains output to this schema
|
|
284
|
+
"options": {"temperature": 0},
|
|
285
|
+
}
|
|
286
|
+
resp = _http_json(OLLAMA_HOST + "/api/chat", payload, {})
|
|
287
|
+
return json.loads(resp["message"]["content"])
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_openai(prompt):
|
|
291
|
+
base = API_BASE or "https://api.openai.com/v1"
|
|
292
|
+
headers = {"Authorization": "Bearer " + API_KEY} if API_KEY else {}
|
|
293
|
+
body = {
|
|
294
|
+
"model": MODEL,
|
|
295
|
+
"messages": [
|
|
296
|
+
{"role": "system", "content": _JSON_SYS},
|
|
297
|
+
{"role": "user", "content": prompt},
|
|
298
|
+
],
|
|
299
|
+
"temperature": 0,
|
|
300
|
+
"response_format": {"type": "json_object"},
|
|
301
|
+
}
|
|
302
|
+
try:
|
|
303
|
+
resp = _http_json(base + "/chat/completions", body, headers)
|
|
304
|
+
except urllib.error.HTTPError as e:
|
|
305
|
+
if e.code == 400: # some compatible servers reject response_format
|
|
306
|
+
body.pop("response_format", None)
|
|
307
|
+
resp = _http_json(base + "/chat/completions", body, headers)
|
|
308
|
+
else:
|
|
309
|
+
raise
|
|
310
|
+
return _extract_json(resp["choices"][0]["message"]["content"])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_anthropic(prompt):
|
|
314
|
+
base = API_BASE or "https://api.anthropic.com"
|
|
315
|
+
headers = {"x-api-key": API_KEY, "anthropic-version": "2023-06-01"}
|
|
316
|
+
body = {
|
|
317
|
+
"model": MODEL,
|
|
318
|
+
"max_tokens": 1024,
|
|
319
|
+
# Force a tool call so the structured fields come back validated.
|
|
320
|
+
"tools": [{
|
|
321
|
+
"name": "record_task",
|
|
322
|
+
"description": "Record the structured fields extracted from the task.",
|
|
323
|
+
"input_schema": SCHEMA,
|
|
324
|
+
}],
|
|
325
|
+
"tool_choice": {"type": "tool", "name": "record_task"},
|
|
326
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
327
|
+
}
|
|
328
|
+
resp = _http_json(base + "/v1/messages", body, headers)
|
|
329
|
+
for block in resp.get("content", []):
|
|
330
|
+
if block.get("type") == "tool_use":
|
|
331
|
+
return block.get("input", {})
|
|
332
|
+
raise ValueError("Anthropic response contained no tool_use block")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
PROVIDERS = {
|
|
336
|
+
"ollama": _parse_ollama,
|
|
337
|
+
"openai": _parse_openai,
|
|
338
|
+
"anthropic": _parse_anthropic,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def llm_parse(text, today):
|
|
343
|
+
fn = PROVIDERS.get(PROVIDER)
|
|
344
|
+
if fn is None:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"unknown JASEM_PROVIDER={PROVIDER!r}; use ollama, openai, or anthropic"
|
|
347
|
+
)
|
|
348
|
+
return fn(_build_prompt(text, today))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def parse_task(text):
|
|
352
|
+
today = dt.date.today()
|
|
353
|
+
try:
|
|
354
|
+
d = llm_parse(text, today)
|
|
355
|
+
title = (d.get("title") or text).strip()
|
|
356
|
+
deadline = resolve_date(
|
|
357
|
+
d.get("deadline_phrase", ""), today, d.get("deadline_date", "")
|
|
358
|
+
)
|
|
359
|
+
# Models sometimes miss the deadline phrase; deterministically rescan
|
|
360
|
+
# the original text as a fallback before giving up.
|
|
361
|
+
if not deadline:
|
|
362
|
+
deadline = resolve_date(text, today)
|
|
363
|
+
priority = d.get("priority", "medium")
|
|
364
|
+
if priority not in PRIORITY_RANK:
|
|
365
|
+
priority = "medium"
|
|
366
|
+
tags = [str(t).strip() for t in d.get("tags", []) if str(t).strip()]
|
|
367
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
368
|
+
sys.stderr.write(
|
|
369
|
+
RED(f"! Could not reach the {PROVIDER} backend ({e}); storing raw text.\n")
|
|
370
|
+
+ DIM(" Check JASEM_PROVIDER / JASEM_MODEL / JASEM_API_KEY, "
|
|
371
|
+
"or that 'ollama serve' is running.\n")
|
|
372
|
+
)
|
|
373
|
+
title, deadline, priority, tags = _local_parse(text, today)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
sys.stderr.write(RED(f"! Parse error ({e}); storing raw text.\n"))
|
|
376
|
+
title, deadline, priority, tags = _local_parse(text, today)
|
|
377
|
+
return {
|
|
378
|
+
"done": False,
|
|
379
|
+
"priority": priority,
|
|
380
|
+
"deadline": deadline,
|
|
381
|
+
"title": _clean(title),
|
|
382
|
+
"tags": ", ".join(tags),
|
|
383
|
+
"created": today.isoformat(),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _local_parse(text, today):
|
|
388
|
+
"""No-AI fallback: keep the raw text, still resolve any date words in it."""
|
|
389
|
+
return text.strip(), resolve_date(text, today), "medium", []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _clean(s):
|
|
393
|
+
# Pipes would break the Markdown table; newlines too.
|
|
394
|
+
return s.replace("|", "/").replace("\n", " ").strip()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ===================== Markdown storage =====================
|
|
398
|
+
PREAMBLE = (
|
|
399
|
+
"# Tasks\n\n"
|
|
400
|
+
"_Managed by the `jasem` CLI. You can hand-edit rows, but keep the column order._\n\n"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def load():
|
|
405
|
+
if not os.path.exists(TASK_FILE):
|
|
406
|
+
return []
|
|
407
|
+
tasks = []
|
|
408
|
+
with open(TASK_FILE, encoding="utf-8") as f:
|
|
409
|
+
for line in f:
|
|
410
|
+
line = line.strip()
|
|
411
|
+
if not line.startswith("|"):
|
|
412
|
+
continue
|
|
413
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
414
|
+
if len(cells) < 7:
|
|
415
|
+
continue
|
|
416
|
+
if cells[0] == "ID" or set(cells[0]) <= set("-: "):
|
|
417
|
+
continue # header or separator row
|
|
418
|
+
try:
|
|
419
|
+
tid = int(cells[0])
|
|
420
|
+
except ValueError:
|
|
421
|
+
continue
|
|
422
|
+
tasks.append({
|
|
423
|
+
"id": tid,
|
|
424
|
+
"done": cells[1] == "☑",
|
|
425
|
+
"priority": cells[2] or "medium",
|
|
426
|
+
"deadline": cells[3] if cells[3] != "-" else "",
|
|
427
|
+
"title": cells[4],
|
|
428
|
+
"tags": cells[5] if cells[5] != "-" else "",
|
|
429
|
+
"created": cells[6],
|
|
430
|
+
})
|
|
431
|
+
return tasks
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def save(tasks):
|
|
435
|
+
rows = [COLS, ["-" * len(c) for c in COLS]]
|
|
436
|
+
for t in tasks:
|
|
437
|
+
rows.append([
|
|
438
|
+
str(t["id"]),
|
|
439
|
+
"☑" if t["done"] else "☐",
|
|
440
|
+
t["priority"],
|
|
441
|
+
t["deadline"] or "-",
|
|
442
|
+
t["title"],
|
|
443
|
+
t["tags"] or "-",
|
|
444
|
+
t["created"],
|
|
445
|
+
])
|
|
446
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(COLS))]
|
|
447
|
+
lines = []
|
|
448
|
+
for ri, r in enumerate(rows):
|
|
449
|
+
if ri == 1: # separator row uses dashes padded with dashes
|
|
450
|
+
lines.append("| " + " | ".join("-" * widths[i] for i in range(len(COLS))) + " |")
|
|
451
|
+
else:
|
|
452
|
+
lines.append("| " + " | ".join(r[i].ljust(widths[i]) for i in range(len(COLS))) + " |")
|
|
453
|
+
os.makedirs(os.path.dirname(TASK_FILE), exist_ok=True)
|
|
454
|
+
with open(TASK_FILE, "w", encoding="utf-8") as f:
|
|
455
|
+
f.write(PREAMBLE + "\n".join(lines) + "\n")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def next_id(tasks):
|
|
459
|
+
return max((t["id"] for t in tasks), default=0) + 1
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ===================== display =====================
|
|
463
|
+
def _sort_key(t):
|
|
464
|
+
# open first, then by deadline (none last), then priority
|
|
465
|
+
return (
|
|
466
|
+
t["done"],
|
|
467
|
+
t["deadline"] or "9999-99-99",
|
|
468
|
+
PRIORITY_RANK.get(t["priority"], 1),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def show(tasks, header):
|
|
473
|
+
today = dt.date.today().isoformat()
|
|
474
|
+
if not tasks:
|
|
475
|
+
print(DIM(" (nothing here)"))
|
|
476
|
+
return
|
|
477
|
+
print(BOLD(header))
|
|
478
|
+
for t in sorted(tasks, key=_sort_key):
|
|
479
|
+
mark = GREEN("☑") if t["done"] else "☐"
|
|
480
|
+
dl = t["deadline"] or "—"
|
|
481
|
+
if not t["done"] and t["deadline"]:
|
|
482
|
+
if t["deadline"] < today:
|
|
483
|
+
dl = RED(dl + " (overdue)")
|
|
484
|
+
elif t["deadline"] == today:
|
|
485
|
+
dl = YELLOW(dl + " (today)")
|
|
486
|
+
prio = t["priority"]
|
|
487
|
+
if prio == "high":
|
|
488
|
+
prio = BOLD(RED(prio))
|
|
489
|
+
elif prio == "low":
|
|
490
|
+
prio = DIM(prio)
|
|
491
|
+
tags = DIM(f"#{t['tags'].replace(', ', ' #')}") if t["tags"] else ""
|
|
492
|
+
title = DIM(t["title"]) if t["done"] else t["title"]
|
|
493
|
+
print(f" {mark} {CYAN(str(t['id']).rjust(3))} [{prio}] {dl} {title} {tags}")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ===================== commands =====================
|
|
497
|
+
def cmd_add(text):
|
|
498
|
+
tasks = load()
|
|
499
|
+
t = parse_task(text)
|
|
500
|
+
t["id"] = next_id(tasks)
|
|
501
|
+
tasks.append(t)
|
|
502
|
+
save(tasks)
|
|
503
|
+
dl = t["deadline"] or "no deadline"
|
|
504
|
+
print(GREEN("✓ added"), f"#{t['id']}:", BOLD(t["title"]))
|
|
505
|
+
print(DIM(f" priority={t['priority']} deadline={dl}"
|
|
506
|
+
+ (f" tags={t['tags']}" if t["tags"] else "")))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def cmd_done(ids):
|
|
510
|
+
tasks = load()
|
|
511
|
+
hit = [t for t in tasks if t["id"] in ids]
|
|
512
|
+
for t in hit:
|
|
513
|
+
t["done"] = True
|
|
514
|
+
save(tasks)
|
|
515
|
+
if hit:
|
|
516
|
+
print(GREEN("✓ completed:"), ", ".join(f"#{t['id']} {t['title']}" for t in hit))
|
|
517
|
+
else:
|
|
518
|
+
print(RED("no matching id(s)"))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cmd_rm(ids):
|
|
522
|
+
tasks = load()
|
|
523
|
+
keep = [t for t in tasks if t["id"] not in ids]
|
|
524
|
+
removed = len(tasks) - len(keep)
|
|
525
|
+
save(keep)
|
|
526
|
+
print(GREEN(f"✓ removed {removed} task(s)") if removed else RED("no matching id(s)"))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# Field name -> accepted aliases, for `jasem set <id> <field> <value>`.
|
|
530
|
+
SET_FIELDS = {
|
|
531
|
+
"priority": {"priority", "prio", "p"},
|
|
532
|
+
"deadline": {"deadline", "due", "date", "d"},
|
|
533
|
+
"category": {"category", "categories", "tag", "tags", "c"},
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _resolve_field(name):
|
|
538
|
+
name = name.lower()
|
|
539
|
+
for field, aliases in SET_FIELDS.items():
|
|
540
|
+
if name in aliases:
|
|
541
|
+
return field
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_set(args):
|
|
546
|
+
if len(args) < 3:
|
|
547
|
+
print(RED("usage: jasem set <id> <priority|deadline|category> <value>"))
|
|
548
|
+
print(DIM(" e.g. jasem set 3 priority high"))
|
|
549
|
+
print(DIM(" jasem set 3 deadline next friday"))
|
|
550
|
+
print(DIM(" jasem set 3 category work finance (none clears it)"))
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
tid = int(args[0])
|
|
555
|
+
except ValueError:
|
|
556
|
+
print(RED(f"not a valid id: {args[0]}"))
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
field = _resolve_field(args[1])
|
|
560
|
+
if not field:
|
|
561
|
+
print(RED(f"unknown field: {args[1]}"))
|
|
562
|
+
print(DIM(" fields: priority · deadline · category"))
|
|
563
|
+
return
|
|
564
|
+
value = " ".join(args[2:]).strip()
|
|
565
|
+
|
|
566
|
+
tasks = load()
|
|
567
|
+
t = next((x for x in tasks if x["id"] == tid), None)
|
|
568
|
+
if not t:
|
|
569
|
+
print(RED(f"no task with id #{tid}"))
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
if field == "priority":
|
|
573
|
+
v = value.lower()
|
|
574
|
+
if v not in PRIORITY_RANK:
|
|
575
|
+
print(RED(f"priority must be one of: {', '.join(PRIORITY_RANK)}"))
|
|
576
|
+
return
|
|
577
|
+
t["priority"] = v
|
|
578
|
+
msg = f"priority → {v}"
|
|
579
|
+
elif field == "deadline":
|
|
580
|
+
if value.lower() in CLEAR_WORDS:
|
|
581
|
+
t["deadline"] = ""
|
|
582
|
+
msg = "deadline cleared"
|
|
583
|
+
else:
|
|
584
|
+
resolved = resolve_date(value, dt.date.today())
|
|
585
|
+
if not resolved:
|
|
586
|
+
print(RED(f"could not understand deadline: {value!r}"))
|
|
587
|
+
print(DIM(" try: tomorrow · next friday · in 3 days · "
|
|
588
|
+
"june 20 · 2026-07-01 · none"))
|
|
589
|
+
return
|
|
590
|
+
t["deadline"] = resolved
|
|
591
|
+
msg = f"deadline → {resolved}"
|
|
592
|
+
else: # category
|
|
593
|
+
if value.lower() in CLEAR_WORDS:
|
|
594
|
+
t["tags"] = ""
|
|
595
|
+
msg = "category cleared"
|
|
596
|
+
else:
|
|
597
|
+
parts = [p for p in (s.strip() for s in re.split(r"[,\s]+", value)) if p]
|
|
598
|
+
t["tags"] = _clean(", ".join(parts))
|
|
599
|
+
msg = f"category → {t['tags']}"
|
|
600
|
+
|
|
601
|
+
save(tasks)
|
|
602
|
+
print(GREEN(f"✓ #{tid} updated:"), msg)
|
|
603
|
+
print(DIM(f" {t['title']}"))
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _tags_of(t):
|
|
607
|
+
"""Lower-cased list of categories/tags on a task."""
|
|
608
|
+
return [x.strip().lower() for x in t["tags"].split(",") if x.strip()]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def cmd_tags():
|
|
612
|
+
counts = {}
|
|
613
|
+
for t in load():
|
|
614
|
+
if t["done"]:
|
|
615
|
+
continue
|
|
616
|
+
for tag in _tags_of(t):
|
|
617
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
618
|
+
if not counts:
|
|
619
|
+
print(DIM(" (no categories yet)"))
|
|
620
|
+
return
|
|
621
|
+
print(BOLD("Categories — open tasks"))
|
|
622
|
+
for tag, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
623
|
+
print(f" {n:>3} {CYAN('#' + tag)}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# ===================== time tracking =====================
|
|
627
|
+
TRACK_PREAMBLE = (
|
|
628
|
+
"# Time log\n\n"
|
|
629
|
+
"_Managed by the `jasem track` CLI. Hand-edit rows freely, but keep the column order._\n\n"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Longest unit spellings first so e.g. "min" wins over a bare "m".
|
|
633
|
+
_DUR_RE = re.compile(
|
|
634
|
+
r"(\d+(?:\.\d+)?)\s*(hours|hour|hrs|hr|h|minutes|minute|mins|min|m)\b"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def parse_duration(text):
|
|
639
|
+
"""Best-effort minutes from free text like '2h', '30 min', '1h 30min'. 0 if unreadable."""
|
|
640
|
+
s = (text or "").lower()
|
|
641
|
+
total = 0.0
|
|
642
|
+
for m in _DUR_RE.finditer(s):
|
|
643
|
+
total += float(m[1]) * 60 if m[2][0] == "h" else float(m[1])
|
|
644
|
+
if total == 0: # a bare number means minutes
|
|
645
|
+
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*", s)
|
|
646
|
+
if m:
|
|
647
|
+
total = float(m[1])
|
|
648
|
+
return int(round(total))
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def fmt_duration(mins):
|
|
652
|
+
h, m = divmod(int(mins), 60)
|
|
653
|
+
if h and m:
|
|
654
|
+
return f"{h}h {m}min"
|
|
655
|
+
return f"{h}h" if h else f"{m}min"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def track_load():
|
|
659
|
+
if not os.path.exists(TRACK_FILE):
|
|
660
|
+
return []
|
|
661
|
+
out = []
|
|
662
|
+
with open(TRACK_FILE, encoding="utf-8") as f:
|
|
663
|
+
for line in f:
|
|
664
|
+
line = line.strip()
|
|
665
|
+
if not line.startswith("|"):
|
|
666
|
+
continue
|
|
667
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
668
|
+
if len(cells) < 4:
|
|
669
|
+
continue
|
|
670
|
+
if cells[0] == "Date" or set(cells[0]) <= set("-: "):
|
|
671
|
+
continue # header or separator row
|
|
672
|
+
out.append({"date": cells[0], "time": cells[1],
|
|
673
|
+
"work": cells[2], "tag": cells[3]})
|
|
674
|
+
return out
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def track_save(entries):
|
|
678
|
+
rows = [TRACK_COLS, ["-" * len(c) for c in TRACK_COLS]]
|
|
679
|
+
for e in entries:
|
|
680
|
+
rows.append([e["date"], e["time"], e["work"], e["tag"] or "work"])
|
|
681
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(TRACK_COLS))]
|
|
682
|
+
lines = []
|
|
683
|
+
for ri, r in enumerate(rows):
|
|
684
|
+
if ri == 1:
|
|
685
|
+
lines.append("| " + " | ".join("-" * widths[i] for i in range(len(TRACK_COLS))) + " |")
|
|
686
|
+
else:
|
|
687
|
+
lines.append("| " + " | ".join(r[i].ljust(widths[i]) for i in range(len(TRACK_COLS))) + " |")
|
|
688
|
+
os.makedirs(os.path.dirname(TRACK_FILE), exist_ok=True)
|
|
689
|
+
with open(TRACK_FILE, "w", encoding="utf-8") as f:
|
|
690
|
+
f.write(TRACK_PREAMBLE + "\n".join(lines) + "\n")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def track_add(text):
|
|
694
|
+
"""Parse '<time>, <work>[, <date>][, <tag>]' and append a log entry."""
|
|
695
|
+
parts = [p.strip() for p in text.split(",") if p.strip()]
|
|
696
|
+
if len(parts) < 2:
|
|
697
|
+
print(RED('usage: jasem track "<time>, <work>[, <date>][, <tag>]"'))
|
|
698
|
+
print(DIM(' e.g. jasem track "2h, coding"'))
|
|
699
|
+
print(DIM(' jasem track "30 min, coding, yesterday, work"'))
|
|
700
|
+
return
|
|
701
|
+
today = dt.date.today()
|
|
702
|
+
time_text, work, extra = parts[0], parts[1], parts[2:]
|
|
703
|
+
# Of the trailing fields, the one that reads as a date is the date; the
|
|
704
|
+
# other is the tag — so order between them doesn't matter.
|
|
705
|
+
date_s, tag = "", ""
|
|
706
|
+
for item in extra:
|
|
707
|
+
resolved = resolve_date(item, today)
|
|
708
|
+
if resolved and not date_s:
|
|
709
|
+
date_s = resolved
|
|
710
|
+
elif not tag:
|
|
711
|
+
tag = item
|
|
712
|
+
date_s = date_s or today.isoformat()
|
|
713
|
+
tag = tag or "work"
|
|
714
|
+
|
|
715
|
+
entries = track_load()
|
|
716
|
+
entries.append({"date": date_s, "time": _clean(time_text),
|
|
717
|
+
"work": _clean(work), "tag": _clean(tag)})
|
|
718
|
+
track_save(entries)
|
|
719
|
+
|
|
720
|
+
mins = parse_duration(time_text)
|
|
721
|
+
when = "today" if date_s == today.isoformat() else date_s
|
|
722
|
+
print(GREEN("✓ tracked"), BOLD(time_text), DIM("·"), work,
|
|
723
|
+
DIM(f"· {when} · #{tag}"))
|
|
724
|
+
if mins == 0:
|
|
725
|
+
sys.stderr.write(YELLOW(
|
|
726
|
+
f" (couldn't read a duration from {time_text!r}; "
|
|
727
|
+
"stored as-is, won't count toward totals)\n"))
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def track_view(period, tag_filter):
|
|
731
|
+
entries = track_load()
|
|
732
|
+
today = dt.date.today()
|
|
733
|
+
today_s = today.isoformat()
|
|
734
|
+
if period == "all":
|
|
735
|
+
sel, header = entries, "Time log — all"
|
|
736
|
+
elif period == "week":
|
|
737
|
+
since = (today - dt.timedelta(days=6)).isoformat()
|
|
738
|
+
sel = [e for e in entries if e["date"] >= since]
|
|
739
|
+
header = "Time log — last 7 days"
|
|
740
|
+
else: # today
|
|
741
|
+
sel = [e for e in entries if e["date"] == today_s]
|
|
742
|
+
header = "Time log — today"
|
|
743
|
+
if tag_filter:
|
|
744
|
+
sel = [e for e in sel if e["tag"].lower() == tag_filter]
|
|
745
|
+
header += " · #" + tag_filter
|
|
746
|
+
|
|
747
|
+
if not sel:
|
|
748
|
+
print(BOLD(header))
|
|
749
|
+
print(DIM(" (nothing tracked)"))
|
|
750
|
+
return
|
|
751
|
+
|
|
752
|
+
by_date = {}
|
|
753
|
+
for e in sel:
|
|
754
|
+
by_date.setdefault(e["date"], []).append(e)
|
|
755
|
+
grand = 0
|
|
756
|
+
print(BOLD(header))
|
|
757
|
+
for date in sorted(by_date, reverse=True):
|
|
758
|
+
day = by_date[date]
|
|
759
|
+
total = sum(parse_duration(e["time"]) for e in day)
|
|
760
|
+
grand += total
|
|
761
|
+
label = date + (" (today)" if date == today_s else "")
|
|
762
|
+
print("\n " + BOLD(CYAN(label)) + DIM(" — ") + BOLD(fmt_duration(total)))
|
|
763
|
+
for e in day:
|
|
764
|
+
tg = DIM(f"#{e['tag']}") if e["tag"] else ""
|
|
765
|
+
print(f" {e['time'].rjust(9)} {e['work']} {tg}")
|
|
766
|
+
if len(by_date) > 1:
|
|
767
|
+
print("\n " + DIM("total ") + BOLD(fmt_duration(grand)))
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def cmd_track(args):
|
|
771
|
+
text = " ".join(args).strip()
|
|
772
|
+
words = text.split()
|
|
773
|
+
first = words[0].lower() if words else ""
|
|
774
|
+
# A new entry has comma-separated fields. A bare duration with no comma is
|
|
775
|
+
# a half-typed entry (missing the work) -> route to add for the usage hint,
|
|
776
|
+
# not to the viewer where it would be mistaken for a tag filter.
|
|
777
|
+
if "," in text or (first not in ("today", "week", "all") and parse_duration(text)):
|
|
778
|
+
track_add(text)
|
|
779
|
+
return
|
|
780
|
+
period = "today"
|
|
781
|
+
if words and words[0] in ("today", "week", "all"):
|
|
782
|
+
period = words.pop(0)
|
|
783
|
+
tag_filter = words[0].lower() if words else None
|
|
784
|
+
track_view(period, tag_filter)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def usage():
|
|
788
|
+
H = lambda s: "\n" + BOLD(CYAN(s)) # section header
|
|
789
|
+
cmd = lambda s: GREEN(s) # command
|
|
790
|
+
ex = lambda s: YELLOW(s) # example / value
|
|
791
|
+
d = DIM # de-emphasised note
|
|
792
|
+
|
|
793
|
+
def row(left, right): # aligned key -> value row
|
|
794
|
+
return " " + GREEN(left.ljust(24)) + right
|
|
795
|
+
|
|
796
|
+
out = [
|
|
797
|
+
BOLD("jasem") + d(" — plain-text task manager + time tracker, pluggable AI parsing"),
|
|
798
|
+
|
|
799
|
+
H("ADD") + d(" wrap the task in quotes; deadline, priority & tags auto-detected"),
|
|
800
|
+
" " + cmd('jasem "') + ex("pay rent next friday, high priority, finance") + cmd('"'),
|
|
801
|
+
" " + cmd('jasem "') + ex("review Ali PR by tomorrow, work") + cmd('"'),
|
|
802
|
+
" " + cmd('jasem add "') + ex("…") + cmd('"') + d(" force-add even if text starts with a command word"),
|
|
803
|
+
" " + d("quotes keep shell chars (& ! * ( )) and words like done/list literal"),
|
|
804
|
+
|
|
805
|
+
H("VIEW") + d(" append a category to filter, e.g. ") + cmd("jasem list work"),
|
|
806
|
+
row("jasem list (ls)", "open tasks, soonest deadline first"),
|
|
807
|
+
row("jasem today", "due today"),
|
|
808
|
+
row("jasem week", "due within the next 7 days"),
|
|
809
|
+
row("jasem overdue", "past deadline, not done " + RED("(red)")),
|
|
810
|
+
row("jasem all", "everything, including completed"),
|
|
811
|
+
row("jasem tags", "list categories in use, with counts"),
|
|
812
|
+
|
|
813
|
+
H("UPDATE"),
|
|
814
|
+
row("jasem done <id>…", "mark task(s) complete"),
|
|
815
|
+
row("jasem rm <id>…", "delete task(s) permanently"),
|
|
816
|
+
row("jasem set <id> priority", ex("high · medium · low")),
|
|
817
|
+
row("jasem set <id> deadline", ex("next friday · in 3 days · 2026-07-01 · none")),
|
|
818
|
+
row("jasem set <id> category", ex("work finance")
|
|
819
|
+
+ d(" (space/comma-separated; ") + ex("none") + d(" clears)")),
|
|
820
|
+
|
|
821
|
+
H("TIME TRACKING") + d(" log durations as plain text; date blank = today, tag blank = work"),
|
|
822
|
+
" " + cmd('jasem track "') + ex("2h, coding") + cmd('"'),
|
|
823
|
+
" " + cmd('jasem track "') + ex("30 min, coding, yesterday, work") + cmd('"'),
|
|
824
|
+
" " + d("format: ") + ex('"<time>, <work>[, <date>][, <tag>]"'),
|
|
825
|
+
row("jasem track", "today's entries, with a daily total"),
|
|
826
|
+
row("jasem track week", "last 7 days, grouped by day"),
|
|
827
|
+
row("jasem track all", "everything; append a tag to filter, e.g. " + cmd("jasem track week work")),
|
|
828
|
+
|
|
829
|
+
H("AI PARSING") + d(" the only step that calls a model; pick a backend with JASEM_PROVIDER"),
|
|
830
|
+
row(" ollama (default)", d("local, no key — run ") + cmd("ollama serve") + d(" + a small model")),
|
|
831
|
+
row(" openai", d("any OpenAI-compatible API — set ") + ex("JASEM_API_KEY")
|
|
832
|
+
+ d(" (+ ") + ex("JASEM_API_BASE") + d(" for non-OpenAI hosts)")),
|
|
833
|
+
row(" anthropic", d("Claude — set ") + ex("JASEM_PROVIDER=anthropic") + d(" + ") + ex("JASEM_API_KEY")),
|
|
834
|
+
" " + d("if the backend is unreachable, the task is still saved (regex dates, no tags)."),
|
|
835
|
+
|
|
836
|
+
H("FILES & CONFIG"),
|
|
837
|
+
row(" provider", ex(PROVIDER) + d(" (JASEM_PROVIDER: ollama · openai · anthropic)")),
|
|
838
|
+
row(" model", ex(MODEL) + d(" (JASEM_MODEL)")),
|
|
839
|
+
row(" tasks", TASK_FILE + d(" (plain Markdown, hand-editable)")),
|
|
840
|
+
row(" time log", TRACK_FILE + d(" (plain Markdown)")),
|
|
841
|
+
row(" env vars", d("JASEM_DIR · JASEM_FILE · JASEM_TRACK_FILE · JASEM_PROVIDER · "
|
|
842
|
+
"JASEM_MODEL · JASEM_API_KEY · JASEM_API_BASE · OLLAMA_HOST")),
|
|
843
|
+
]
|
|
844
|
+
print("\n".join(out))
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def main(argv):
|
|
848
|
+
if not argv or argv[0] in ("help", "-h", "--help"):
|
|
849
|
+
usage()
|
|
850
|
+
return
|
|
851
|
+
cmd = argv[0]
|
|
852
|
+
rest = argv[1:]
|
|
853
|
+
today = dt.date.today()
|
|
854
|
+
today_s = today.isoformat()
|
|
855
|
+
wk_s = (today + dt.timedelta(days=7)).isoformat()
|
|
856
|
+
|
|
857
|
+
VIEWS = {
|
|
858
|
+
"list": (lambda t: not t["done"], "Open tasks"),
|
|
859
|
+
"ls": (lambda t: not t["done"], "Open tasks"),
|
|
860
|
+
"all": (lambda t: True, "All tasks"),
|
|
861
|
+
"today": (lambda t: not t["done"] and t["deadline"] == today_s, "Due today"),
|
|
862
|
+
"week": (lambda t: not t["done"] and t["deadline"]
|
|
863
|
+
and today_s <= t["deadline"] <= wk_s, "Due within 7 days"),
|
|
864
|
+
"overdue": (lambda t: not t["done"] and t["deadline"]
|
|
865
|
+
and t["deadline"] < today_s, "Overdue"),
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if cmd == "tags":
|
|
869
|
+
cmd_tags()
|
|
870
|
+
elif cmd in VIEWS:
|
|
871
|
+
pred, header = VIEWS[cmd]
|
|
872
|
+
tasks = [t for t in load() if pred(t)]
|
|
873
|
+
if rest: # optional category filter(s): jasem list work [urgent]
|
|
874
|
+
cats = [c.strip().lower() for c in rest]
|
|
875
|
+
tasks = [t for t in tasks if all(c in _tags_of(t) for c in cats)]
|
|
876
|
+
header += " · " + " ".join("#" + c for c in cats)
|
|
877
|
+
show(tasks, header)
|
|
878
|
+
elif cmd in ("done", "rm"):
|
|
879
|
+
ids = set()
|
|
880
|
+
for a in rest:
|
|
881
|
+
try:
|
|
882
|
+
ids.add(int(a))
|
|
883
|
+
except ValueError:
|
|
884
|
+
pass
|
|
885
|
+
if not ids:
|
|
886
|
+
print(RED(f"usage: jasem {cmd} <id> [id...]"))
|
|
887
|
+
print(DIM(f' (to add a task that starts with "{cmd}", quote it: '
|
|
888
|
+
f'jasem add "{cmd} ...")'))
|
|
889
|
+
return
|
|
890
|
+
(cmd_done if cmd == "done" else cmd_rm)(ids)
|
|
891
|
+
elif cmd in ("set", "edit"):
|
|
892
|
+
cmd_set(rest)
|
|
893
|
+
elif cmd == "track":
|
|
894
|
+
cmd_track(rest)
|
|
895
|
+
elif cmd == "add":
|
|
896
|
+
if len(argv) < 2:
|
|
897
|
+
print(RED("usage: jasem add <description>"))
|
|
898
|
+
return
|
|
899
|
+
cmd_add(" ".join(argv[1:]))
|
|
900
|
+
else:
|
|
901
|
+
# no recognized subcommand -> treat the whole input as a new task
|
|
902
|
+
cmd_add(" ".join(argv))
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def cli():
|
|
906
|
+
"""Console-script entry point (see pyproject.toml [project.scripts])."""
|
|
907
|
+
main(sys.argv[1:])
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
if __name__ == "__main__":
|
|
911
|
+
cli()
|