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 +5 -0
- pysince-0.1.0/pyproject.toml +16 -0
- pysince-0.1.0/pysince.egg-info/PKG-INFO +5 -0
- pysince-0.1.0/pysince.egg-info/SOURCES.txt +22 -0
- pysince-0.1.0/pysince.egg-info/dependency_links.txt +1 -0
- pysince-0.1.0/pysince.egg-info/entry_points.txt +2 -0
- pysince-0.1.0/pysince.egg-info/top_level.txt +1 -0
- pysince-0.1.0/setup.cfg +4 -0
- pysince-0.1.0/since/__init__.py +8 -0
- pysince-0.1.0/since/cli.py +101 -0
- pysince-0.1.0/since/core.py +52 -0
- pysince-0.1.0/since/decorator.py +81 -0
- pysince-0.1.0/since/format.py +173 -0
- pysince-0.1.0/since/models.py +37 -0
- pysince-0.1.0/since/stale_files.py +74 -0
- pysince-0.1.0/since/store.py +198 -0
- pysince-0.1.0/since/timeparse.py +299 -0
- pysince-0.1.0/tests/test_decorator.py +133 -0
- pysince-0.1.0/tests/test_format.py +96 -0
- pysince-0.1.0/tests/test_integration.py +46 -0
- pysince-0.1.0/tests/test_retrieval.py +89 -0
- pysince-0.1.0/tests/test_stale_files.py +103 -0
- pysince-0.1.0/tests/test_timeparse.py +120 -0
- pysince-0.1.0/tests/test_ttl.py +157 -0
pysince-0.1.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
since
|
pysince-0.1.0/setup.cfg
ADDED
|
@@ -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
|