pysince 0.1.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.
pysince-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysince
3
+ Version: 0.1.0
4
+ Summary: Temporal context for LLM conversations
5
+ Requires-Python: >=3.10
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pysince"
7
+ version = "0.1.0"
8
+ description = "Temporal context for LLM conversations"
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ pysince = "since.cli:main"
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["since*"]
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysince
3
+ Version: 0.1.0
4
+ Summary: Temporal context for LLM conversations
5
+ Requires-Python: >=3.10
@@ -0,0 +1,22 @@
1
+ pyproject.toml
2
+ pysince.egg-info/PKG-INFO
3
+ pysince.egg-info/SOURCES.txt
4
+ pysince.egg-info/dependency_links.txt
5
+ pysince.egg-info/entry_points.txt
6
+ pysince.egg-info/top_level.txt
7
+ since/__init__.py
8
+ since/cli.py
9
+ since/core.py
10
+ since/decorator.py
11
+ since/format.py
12
+ since/models.py
13
+ since/stale_files.py
14
+ since/store.py
15
+ since/timeparse.py
16
+ tests/test_decorator.py
17
+ tests/test_format.py
18
+ tests/test_integration.py
19
+ tests/test_retrieval.py
20
+ tests/test_stale_files.py
21
+ tests/test_timeparse.py
22
+ tests/test_ttl.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pysince = since.cli:main
@@ -0,0 +1 @@
1
+ since
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ from .core import enrich_message, with_time
2
+ from .decorator import since_time
3
+ from .models import Message
4
+ from .store import Store
5
+
6
+ sense_time = since_time
7
+
8
+ __all__ = ["Message", "Store", "enrich_message", "sense_time", "since_time", "with_time"]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import datetime
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .core import with_time
9
+ from .store import Store
10
+
11
+
12
+ def cmd_enrich(args: argparse.Namespace) -> None:
13
+ store = Store(args.db)
14
+ messages = store.load_session(args.session_id)
15
+ if not messages:
16
+ print(f"Session '{args.session_id}' not found", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+ prompt = with_time(messages, store=store)
20
+ for entry in prompt:
21
+ print(f"[{entry['role']}]\n{entry['content']}\n")
22
+
23
+
24
+ def cmd_search(args: argparse.Namespace) -> None:
25
+ store = Store(args.db)
26
+ messages = store.load_session(args.session_id)
27
+ if not messages:
28
+ print(f"Session '{args.session_id}' not found", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
32
+
33
+ if args.range:
34
+ parts = args.range.split()
35
+ if len(parts) == 2 and parts[1] in ("ago",):
36
+ try:
37
+ value = int(parts[0])
38
+ except ValueError:
39
+ print(f"Invalid range: {args.range}", file=sys.stderr)
40
+ sys.exit(1)
41
+ start = now - datetime.timedelta(days=value)
42
+ else:
43
+ try:
44
+ start = datetime.datetime.fromisoformat(args.range)
45
+ except ValueError:
46
+ print(f"Invalid range: {args.range}", file=sys.stderr)
47
+ sys.exit(1)
48
+ end = now
49
+ results = store.load_range(args.session_id, start, end)
50
+ else:
51
+ results = messages[-20:]
52
+
53
+ from .format import format_absolute
54
+ for m in results:
55
+ print(format_absolute(m))
56
+
57
+
58
+ def cmd_stats(args: argparse.Namespace) -> None:
59
+ store = Store(args.db)
60
+ info = store.session_info(args.session_id)
61
+ if not info:
62
+ print(f"Session '{args.session_id}' not found", file=sys.stderr)
63
+ sys.exit(1)
64
+
65
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
66
+ from .format import _format_timedelta_long
67
+ print(f"Session: {args.session_id}")
68
+ print(f"Messages: {info['count']}")
69
+ print(f"Started: {info['first'].isoformat()}")
70
+ print(f"Last message: {info['last'].isoformat()}")
71
+ print(f"Duration: {_format_timedelta_long(info['last'] - info['first'])}")
72
+ print(f"Age: {_format_timedelta_long(now - info['first'])}")
73
+
74
+
75
+ def main() -> None:
76
+ parser = argparse.ArgumentParser(prog="since", description="Temporal context for LLM conversations")
77
+ parser.add_argument("--db", default="~/.ticks/store.db", type=str, help="SQLite database path")
78
+
79
+ sub = parser.add_subparsers(dest="command", required=True)
80
+
81
+ p_enrich = sub.add_parser("enrich", help="Enrich a session with temporal context")
82
+ p_enrich.add_argument("session_id", type=str)
83
+ p_enrich.set_defaults(func=cmd_enrich)
84
+
85
+ p_search = sub.add_parser("search", help="Search messages by time range")
86
+ p_search.add_argument("session_id", type=str)
87
+ p_search.add_argument("--range", type=str, default="", help="Time range (e.g. '3 days ago' or ISO 8601)")
88
+ p_search.set_defaults(func=cmd_search)
89
+
90
+ p_stats = sub.add_parser("stats", help="Show session statistics")
91
+ p_stats.add_argument("session_id", type=str)
92
+ p_stats.set_defaults(func=cmd_stats)
93
+
94
+ args = parser.parse_args()
95
+ args.db = Path(Path.home() / ".ticks" / "store.db") if args.db == "~/.ticks/store.db" else Path(args.db)
96
+ args.db.parent.mkdir(parents=True, exist_ok=True)
97
+ args.func(args)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ from .format import build_prompt, format_absolute
6
+ from .models import Message
7
+ from .store import Store
8
+ from .timeparse import detect_temporal, parse_temporal
9
+
10
+
11
+ def with_time(
12
+ messages: list[Message],
13
+ now: datetime.datetime | None = None,
14
+ store: Store | None = None,
15
+ include_nudge: bool = True,
16
+ user_input: str | None = None,
17
+ tz_name: str = "UTC",
18
+ ) -> list[dict]:
19
+ if now is None:
20
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
21
+
22
+ extra_context = None
23
+ session_id = messages[0].session_id if messages else None
24
+
25
+ if store and session_id and user_input and detect_temporal(user_input):
26
+ parsed = parse_temporal(user_input, now)
27
+ if parsed:
28
+ start, end = parsed
29
+ retrieved = store.load_range(session_id, start, end)
30
+ existing_ids = {(m.session_id, m.turn_id) for m in messages}
31
+ extra_context = [m for m in retrieved if (m.session_id, m.turn_id) not in existing_ids]
32
+
33
+ stale_info = None
34
+ if store and session_id:
35
+ stale_info = store.stale_messages(session_id, now)
36
+
37
+ prompt = build_prompt(
38
+ messages=messages,
39
+ now=now,
40
+ include_nudge=include_nudge,
41
+ extra_context=extra_context,
42
+ tz_name=tz_name,
43
+ stale_info=stale_info,
44
+ )
45
+
46
+ return prompt
47
+
48
+
49
+ def enrich_message(msg: Message, now: datetime.datetime | None = None, tz_name: str = "UTC") -> str:
50
+ if now is None:
51
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
52
+ return format_absolute(msg, tz_name)
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import functools
5
+ import re
6
+ from typing import Any, Callable
7
+
8
+ _TS_PREFIX = re.compile(
9
+ r'^\[[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} ?(?:AM|PM)\]\s*'
10
+ )
11
+
12
+ from .core import with_time
13
+ from .models import Message
14
+ from .store import Store
15
+
16
+
17
+ def since_time(
18
+ store: Store,
19
+ session_id: str | None = None,
20
+ timezone: str = "UTC",
21
+ ) -> Callable:
22
+ def decorator(func: Callable) -> Callable:
23
+ sid = session_id or func.__name__
24
+
25
+ @functools.wraps(func)
26
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
27
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
28
+
29
+ ttl_class = kwargs.pop("__ttl_class__", "slow")
30
+ source_id = kwargs.pop("__source_id__", None)
31
+
32
+ messages = kwargs.get("messages")
33
+ if messages is None and args:
34
+ messages = args[0]
35
+
36
+ if not messages or not isinstance(messages, list):
37
+ return func(*args, **kwargs)
38
+
39
+ last_user = None
40
+ for m in reversed(messages):
41
+ if m.get("role") == "user":
42
+ last_user = m
43
+ break
44
+
45
+ user_text = last_user.get("content", "") if last_user else ""
46
+
47
+ if last_user:
48
+ turn = store.next_turn(sid)
49
+ msg = Message(sid, turn, "user", user_text, now,
50
+ timezone=timezone, ttl_class=ttl_class, source_id=source_id)
51
+ store.insert(msg)
52
+
53
+ history = store.load_session(sid)
54
+ enriched = with_time(history, now=now, store=store, user_input=user_text, tz_name=timezone)
55
+
56
+ new_kwargs = dict(kwargs)
57
+ new_kwargs["messages"] = enriched
58
+ result = func(*args, **new_kwargs)
59
+
60
+ reply = None
61
+ if hasattr(result, "choices") and result.choices:
62
+ choice = result.choices[0]
63
+ if hasattr(choice, "message") and hasattr(choice.message, "content"):
64
+ reply = choice.message.content
65
+ elif isinstance(choice, dict):
66
+ reply = choice.get("message", {}).get("content")
67
+
68
+ if reply:
69
+ clean = reply
70
+ while _TS_PREFIX.match(clean):
71
+ clean = _TS_PREFIX.sub("", clean)
72
+ turn = store.next_turn(sid)
73
+ msg = Message(sid, turn, "assistant", clean, now,
74
+ timezone=timezone, ttl_class=ttl_class, source_id=source_id)
75
+ store.insert(msg)
76
+
77
+ return result
78
+
79
+ return wrapper
80
+
81
+ return decorator
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import re
5
+ import zoneinfo
6
+
7
+ from .models import Message, StaleInfo, TIME_OF_DAY_BANDS
8
+
9
+ PROMPTING_NUDGE = (
10
+ "Every message has a UTC timestamp. The 'Now:' line below is the "
11
+ "current time — use it for all time references. Never guess the time."
12
+ )
13
+
14
+ GAP_THRESHOLD = datetime.timedelta(minutes=30)
15
+
16
+
17
+ def _utc_to_local(dt: datetime.datetime, tz_name: str) -> datetime.datetime:
18
+ if tz_name == "UTC":
19
+ return dt
20
+ if tz_name.startswith("UTC") and len(tz_name) > 3:
21
+ sign = 1 if tz_name[3] == "+" else -1
22
+ parts = tz_name[4:].split(":")
23
+ h, m = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
24
+ offset = datetime.timedelta(hours=sign * h, minutes=sign * m)
25
+ tz = datetime.timezone(offset)
26
+ return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz).replace(tzinfo=None)
27
+ try:
28
+ tz = zoneinfo.ZoneInfo(tz_name)
29
+ return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz).replace(tzinfo=None)
30
+ except zoneinfo.ZoneInfoNotFoundError:
31
+ return dt
32
+
33
+
34
+ def _time_of_day_band(dt: datetime.datetime) -> str:
35
+ hour = dt.hour
36
+ for (start, end), label in TIME_OF_DAY_BANDS.items():
37
+ if start <= hour < end:
38
+ return label
39
+ return "night"
40
+
41
+
42
+ def _format_timedelta_short(td: datetime.timedelta) -> str:
43
+ total_seconds = int(td.total_seconds())
44
+ if total_seconds < 0:
45
+ return "in the future"
46
+ if total_seconds < 60:
47
+ return "just now"
48
+ if total_seconds < 3600:
49
+ m = total_seconds // 60
50
+ return f"{m}m ago"
51
+ if total_seconds < 86400:
52
+ h = total_seconds // 3600
53
+ m = (total_seconds % 3600) // 60
54
+ return f"{h}h {m}m ago" if m else f"{h}h ago"
55
+ if total_seconds < 604800:
56
+ d = total_seconds // 86400
57
+ h = (total_seconds % 86400) // 3600
58
+ return f"{d}d {h}h ago" if h else f"{d}d ago"
59
+ days = total_seconds // 86400
60
+ if days < 30:
61
+ w = days // 7
62
+ d = days % 7
63
+ return f"{w}w {d}d ago" if d else f"{w}w ago"
64
+ if days < 365:
65
+ mo = days // 30
66
+ d = days % 30
67
+ return f"{mo}mo {d}d ago" if d else f"{mo}mo ago"
68
+ y = days // 365
69
+ d = days % 365
70
+ return f"{y}y {d}d ago" if d else f"{y}y ago"
71
+
72
+
73
+ def _format_timedelta_compact(td: datetime.timedelta) -> str:
74
+ total_seconds = int(td.total_seconds())
75
+ if total_seconds < 0:
76
+ return "0s"
77
+ if total_seconds < 60:
78
+ return f"{total_seconds}s"
79
+ if total_seconds < 3600:
80
+ return f"{total_seconds // 60}m"
81
+ if total_seconds < 86400:
82
+ return f"{total_seconds // 3600}h {(total_seconds % 3600) // 60}m"
83
+ days = total_seconds // 86400
84
+ h = (total_seconds % 86400) // 3600
85
+ return f"{days}d {h}h"
86
+
87
+
88
+ def _format_absolute_label(dt: datetime.datetime) -> str:
89
+ return dt.strftime("%a %b %d, %I:%M%p").lstrip("0").replace(" ", " ")
90
+
91
+
92
+ _TS_PREFIX = re.compile(
93
+ r'^\[[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} ?(?:AM|PM)\]\s*'
94
+ )
95
+
96
+
97
+ def format_absolute(msg: Message, tz_name: str = "UTC") -> str:
98
+ ts = _utc_to_local(msg.created_at, tz_name)
99
+ clean = _TS_PREFIX.sub("", msg.content)
100
+ return f"[{_format_absolute_label(ts)}] {clean}"
101
+
102
+
103
+ def _build_tail(
104
+ history: list[Message],
105
+ now: datetime.datetime,
106
+ tz_name: str = "UTC",
107
+ stale_info: list[StaleInfo] | None = None,
108
+ ) -> str:
109
+ lines = []
110
+
111
+ local_now = _utc_to_local(now, tz_name) if tz_name != "UTC" else now
112
+ now_str = local_now.strftime("%a %b %d, %I:%M %p").lstrip("0").replace(" ", " ")
113
+ band = _time_of_day_band(local_now)
114
+ lines.append(f"Now: {now_str} ({band})")
115
+
116
+ if history:
117
+ session_age = now - history[0].created_at
118
+ last_gap = now - history[-1].created_at
119
+
120
+ lines.append(f"Session: {_format_timedelta_compact(session_age)} · {len(history) + 1} messages")
121
+
122
+ if last_gap > GAP_THRESHOLD:
123
+ lines.append(f"Gap: {_format_timedelta_short(last_gap)} between messages — welcome back!")
124
+ elif last_gap.total_seconds() > 60:
125
+ lines.append(f"Last message: {_format_timedelta_short(last_gap)}")
126
+ else:
127
+ lines.append(f"Session: just started · 1 message")
128
+
129
+ if stale_info:
130
+ for s in stale_info:
131
+ kind = {"event": "invalidated", "ephemeral": "expired"}.get(s.ttl_class, s.ttl_class)
132
+ src = f" ({s.source_id})" if s.source_id else ""
133
+ lines.append(f"⚠ Stale: \"{s.content_preview}\"{src} — {kind}, {_format_timedelta_compact(s.age)} old")
134
+
135
+ return "\n".join(lines)
136
+
137
+
138
+ def build_prompt(
139
+ messages: list[Message],
140
+ now: datetime.datetime,
141
+ include_nudge: bool = True,
142
+ extra_context: list[Message] | None = None,
143
+ tz_name: str = "UTC",
144
+ stale_info: list[StaleInfo] | None = None,
145
+ ) -> list[dict]:
146
+ result = []
147
+
148
+ if include_nudge:
149
+ result.append({"role": "system", "content": PROMPTING_NUDGE})
150
+
151
+ if extra_context:
152
+ result.append({"role": "system", "content": f"--- Retrieved from history ({len(extra_context)} messages) ---"})
153
+ for msg in extra_context:
154
+ result.append({"role": msg.role, "content": format_absolute(msg, tz_name)})
155
+ result.append({"role": "system", "content": "--- End of retrieved history ---"})
156
+
157
+ history_msgs = messages
158
+ current_content = None
159
+
160
+ if messages and messages[-1].role == "user":
161
+ history_msgs = messages[:-1]
162
+ current_content = messages[-1].content
163
+
164
+ for msg in history_msgs:
165
+ result.append({"role": msg.role, "content": format_absolute(msg, tz_name)})
166
+
167
+ tail = _build_tail(history_msgs, now, tz_name, stale_info)
168
+ result.append({"role": "system", "content": tail})
169
+
170
+ if current_content:
171
+ result.append({"role": "user", "content": current_content})
172
+
173
+ return result
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Message:
9
+ session_id: str
10
+ turn_id: int
11
+ role: str
12
+ content: str
13
+ created_at: datetime.datetime
14
+ timezone: str = "UTC"
15
+ ttl_class: str = "slow"
16
+ source_id: str | None = None
17
+ invalidated_at: datetime.datetime | None = None
18
+
19
+
20
+ @dataclass
21
+ class StaleInfo:
22
+ turn_id: int
23
+ ttl_class: str
24
+ source_id: str | None
25
+ content_preview: str
26
+ age: datetime.timedelta
27
+
28
+
29
+ TIME_OF_DAY_BANDS = {
30
+ (0, 5): "night",
31
+ (5, 12): "morning",
32
+ (12, 17): "afternoon",
33
+ (17, 21): "evening",
34
+ (21, 24): "night",
35
+ }
36
+
37
+ EPHEMERAL_TTL = datetime.timedelta(minutes=5)
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .models import Message
8
+ from .store import Store
9
+
10
+
11
+ def _now() -> datetime.datetime:
12
+ return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
13
+
14
+
15
+ def stamp_file_read(filepath: str, store: Store, session_id: str) -> str:
16
+ path = Path(filepath).resolve()
17
+ if not path.exists():
18
+ raise FileNotFoundError(str(path))
19
+ mtime = os.path.getmtime(str(path))
20
+ source_id = f"read:{path}"
21
+ content = f"[FILE READ] {path} (mtime={mtime})"
22
+ turn = store.next_turn(session_id)
23
+ msg = Message(session_id, turn, "user", content, _now(),
24
+ ttl_class="event", source_id=source_id)
25
+ store.insert(msg)
26
+ msg2 = Message(session_id, turn + 1, "assistant",
27
+ f"I read {path.name} ({path}).",
28
+ _now(), ttl_class="event", source_id=source_id)
29
+ store.insert(msg2)
30
+ return source_id
31
+
32
+
33
+ def is_stale(filepath: str, store: Store, session_id: str) -> bool:
34
+ path = Path(filepath).resolve()
35
+ if not path.exists():
36
+ return True
37
+ current_mtime = os.path.getmtime(str(path))
38
+ source_id = f"read:{path}"
39
+ msgs = store.load_session(session_id)
40
+ for m in reversed(msgs):
41
+ if m.source_id == source_id and m.ttl_class == "event" and m.invalidated_at is None:
42
+ try:
43
+ stored = float(m.content.split("mtime=")[1].rstrip(")"))
44
+ except (IndexError, ValueError):
45
+ continue
46
+ if current_mtime != stored:
47
+ return True
48
+ return False
49
+ return False
50
+
51
+
52
+ def check_and_invalidate(filepath: str, store: Store, session_id: str) -> bool:
53
+ """Returns True if the file was found stale and invalidated."""
54
+ path = Path(filepath).resolve()
55
+ abs_path = str(path)
56
+ source_id = f"read:{abs_path}"
57
+ try:
58
+ current_mtime = os.path.getmtime(abs_path)
59
+ except FileNotFoundError:
60
+ return False
61
+ msgs = store.load_session(session_id)
62
+ found = False
63
+ for m in reversed(msgs):
64
+ if m.source_id == source_id and m.ttl_class == "event" and m.invalidated_at is None:
65
+ found = True
66
+ try:
67
+ stored = float(m.content.split("mtime=")[1].rstrip(")"))
68
+ except (IndexError, ValueError):
69
+ continue
70
+ if current_mtime != stored:
71
+ n = store.invalidate(source_id)
72
+ return n > 0
73
+ return False
74
+ return found