personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Extract shared markdown segment writer, DRY importers, add RRULE descriptions

Add write_markdown_segments() to shared.py to encapsulate the repeated
segment-creation loop (mkdir + render + write imported.md + track files).
Replace inline loops in ICS, Obsidian, Kindle, and Gemini importers.

Delete Obsidian's _window_notes() duplicate and use shared window_items()
with ts_key="mtime", tz=None instead.

Parse RRULE from ICS VEVENT components and render human-readable
recurrence descriptions (e.g. "Weekly on Mon", "Every 2 days, 10 times")
via new _describe_rrule() helper.

Tests: write_markdown_segments coverage, RRULE description unit tests
(weekly+byday, daily+interval, monthly+byday, count, until, yearly, empty),
render with recurrence, process segments with recurring event.

+254 -103
+100 -1
tests/test_importer.py
··· 301 301 assert entry["source"] == "import" 302 302 303 303 304 + def test_write_markdown_segments(tmp_path, monkeypatch): 305 + """write_markdown_segments creates segment dirs with imported.md files.""" 306 + mod = importlib.import_module("think.importers.shared") 307 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 308 + 309 + windows = [ 310 + ("20260301", "120000_300", [{"text": "hello"}, {"text": "world"}]), 311 + ("20260302", "090000_300", [{"text": "morning"}]), 312 + ] 313 + 314 + def render(items): 315 + return "\n\n".join(item["text"] for item in items) 316 + 317 + files, segments = mod.write_markdown_segments("test", windows, render) 318 + 319 + assert len(files) == 2 320 + assert len(segments) == 2 321 + 322 + first_md = day_path("20260301") / "import.test" / "120000_300" / "imported.md" 323 + assert first_md.exists() 324 + content = first_md.read_text() 325 + assert "hello" in content 326 + assert "world" in content 327 + assert content.endswith("\n") 328 + 329 + second_md = day_path("20260302") / "import.test" / "090000_300" / "imported.md" 330 + assert second_md.exists() 331 + 332 + assert segments == [("20260301", "120000_300"), ("20260302", "090000_300")] 333 + 334 + 304 335 def test_chatgpt_importer_segments(tmp_path, monkeypatch): 305 336 """ChatGPT importer should write message windows as import segments.""" 306 337 mod = importlib.import_module("think.importers.chatgpt") ··· 1119 1150 assert rendered == "## Created Only Event" 1120 1151 1121 1152 1153 + def test_ics_render_event_markdown_with_recurrence(): 1154 + mod = importlib.import_module("think.importers.ics") 1155 + event = { 1156 + "title": "Weekly Standup", 1157 + "ts": "2026-03-15T11:00:00+00:00", 1158 + "end_ts": "2026-03-15T11:30:00+00:00", 1159 + "duration_minutes": 30, 1160 + "recurrence": "Weekly on Mon", 1161 + } 1162 + rendered = mod._render_event_markdown(event) 1163 + assert "## Weekly Standup" in rendered 1164 + assert "🔁 Weekly on Mon" in rendered 1165 + 1166 + 1167 + def test_ics_describe_rrule_weekly_byday(): 1168 + mod = importlib.import_module("think.importers.ics") 1169 + rrule = {"FREQ": ["WEEKLY"], "BYDAY": ["MO", "WE", "FR"]} 1170 + assert mod._describe_rrule(rrule) == "Weekly on Mon, Wed, Fri" 1171 + 1172 + 1173 + def test_ics_describe_rrule_daily_interval(): 1174 + mod = importlib.import_module("think.importers.ics") 1175 + rrule = {"FREQ": ["DAILY"], "INTERVAL": [2]} 1176 + assert mod._describe_rrule(rrule) == "Every 2 days" 1177 + 1178 + 1179 + def test_ics_describe_rrule_monthly_byday(): 1180 + mod = importlib.import_module("think.importers.ics") 1181 + rrule = {"FREQ": ["MONTHLY"], "BYDAY": ["TU"]} 1182 + assert mod._describe_rrule(rrule) == "Monthly on Tue" 1183 + 1184 + 1185 + def test_ics_describe_rrule_with_count(): 1186 + mod = importlib.import_module("think.importers.ics") 1187 + rrule = {"FREQ": ["WEEKLY"], "BYDAY": ["MO"], "COUNT": [10]} 1188 + assert mod._describe_rrule(rrule) == "Weekly on Mon, 10 times" 1189 + 1190 + 1191 + def test_ics_describe_rrule_with_until(): 1192 + mod = importlib.import_module("think.importers.ics") 1193 + until_dt = dt.datetime(2026, 12, 31, tzinfo=dt.timezone.utc) 1194 + rrule = { 1195 + "FREQ": ["WEEKLY"], 1196 + "BYDAY": ["MO", "WE", "FR"], 1197 + "UNTIL": [until_dt], 1198 + } 1199 + assert mod._describe_rrule(rrule) == "Weekly on Mon, Wed, Fri, until 2026-12-31" 1200 + 1201 + 1202 + def test_ics_describe_rrule_yearly(): 1203 + mod = importlib.import_module("think.importers.ics") 1204 + rrule = {"FREQ": ["YEARLY"], "BYMONTH": [3], "BYMONTHDAY": [15]} 1205 + assert mod._describe_rrule(rrule) == "Yearly on day 15 in Mar" 1206 + 1207 + 1208 + def test_ics_describe_rrule_empty(): 1209 + mod = importlib.import_module("think.importers.ics") 1210 + assert mod._describe_rrule({}) == "" 1211 + 1212 + 1122 1213 def test_ics_process_segments(tmp_path, monkeypatch): 1123 1214 mod = importlib.import_module("think.importers.ics") 1124 1215 ··· 1141 1232 CREATED:20260301T120200Z 1142 1233 END:VEVENT 1143 1234 BEGIN:VEVENT 1235 + DTSTART:20260315T110000Z 1236 + DTEND:20260315T113000Z 1237 + SUMMARY:Weekly Standup 1238 + CREATED:20260301T120100Z 1239 + RRULE:FREQ=WEEKLY;BYDAY=MO 1240 + END:VEVENT 1241 + BEGIN:VEVENT 1144 1242 DTSTART:20260317T090000Z 1145 1243 DTEND:20260317T093000Z 1146 1244 SUMMARY:Event Three ··· 1156 1254 first_md = day_path("20260301") / "import.ics" / "120000_300" / "imported.md" 1157 1255 second_md = day_path("20260302") / "import.ics" / "090000_300" / "imported.md" 1158 1256 1159 - assert result.entries_written == 3 1257 + assert result.entries_written == 4 1160 1258 assert result.errors == [] 1161 1259 assert result.segments == [ 1162 1260 ("20260301", "120000_300"), ··· 1169 1267 second_content = second_md.read_text() 1170 1268 assert "## Event One" in first_content 1171 1269 assert "First description" in first_content 1270 + assert "🔁 Weekly on Mon" in first_content 1172 1271 assert "## Event Two" in first_content 1173 1272 assert "## Event Three" in second_content 1174 1273 assert "**2026-03-17 09:00 AM – 09:30 AM** (30 min)" in second_content
+6 -15
think/importers/gemini.py
··· 23 23 from typing import Any, Callable 24 24 25 25 from think.importers.file_importer import ImportPreview, ImportResult 26 - from think.importers.shared import window_items 27 - from think.utils import day_path 26 + from think.importers.shared import window_items, write_markdown_segments 28 27 29 28 logger = logging.getLogger(__name__) 30 29 ··· 285 284 entries.sort(key=lambda e: e["create_ts"]) 286 285 287 286 windows = window_items(entries, "create_ts") 288 - created_files: list[str] = [] 289 - segments: list[tuple[str, str]] = [] 290 - 291 - for day, seg_key, window_activities in windows: 292 - segment_dir = day_path(day) / "import.gemini" / seg_key 293 - segment_dir.mkdir(parents=True, exist_ok=True) 294 - md_path = segment_dir / "imported.md" 295 - markdown = "\n\n".join( 296 - _render_activity_markdown(act) for act in window_activities 297 - ) 298 - md_path.write_text(markdown + "\n", encoding="utf-8") 299 - created_files.append(str(md_path)) 300 - segments.append((day, seg_key)) 287 + created_files, segments = write_markdown_segments( 288 + "gemini", 289 + windows, 290 + lambda items: "\n\n".join(_render_activity_markdown(a) for a in items), 291 + ) 301 292 302 293 segment_days = {day for day, _ in segments} 303 294
+95 -15
think/importers/ics.py
··· 10 10 from typing import Any, Callable 11 11 12 12 from think.importers.file_importer import ImportPreview, ImportResult 13 - from think.importers.shared import seed_entities, window_items 14 - from think.utils import day_path 13 + from think.importers.shared import seed_entities, window_items, write_markdown_segments 15 14 16 15 logger = logging.getLogger(__name__) 17 16 ··· 126 125 return None 127 126 128 127 128 + def _describe_rrule(rrule: dict[str, Any]) -> str: 129 + """Convert an icalendar vRecur dict to a human-readable description.""" 130 + freq_list = rrule.get("FREQ", []) 131 + if not freq_list: 132 + return "" 133 + freq = str(freq_list[0]) 134 + 135 + interval = int(rrule.get("INTERVAL", [1])[0]) 136 + 137 + day_names = { 138 + "MO": "Mon", 139 + "TU": "Tue", 140 + "WE": "Wed", 141 + "TH": "Thu", 142 + "FR": "Fri", 143 + "SA": "Sat", 144 + "SU": "Sun", 145 + } 146 + 147 + freq_map = { 148 + "DAILY": ("day", "days", "Daily"), 149 + "WEEKLY": ("week", "weeks", "Weekly"), 150 + "MONTHLY": ("month", "months", "Monthly"), 151 + "YEARLY": ("year", "years", "Yearly"), 152 + } 153 + if freq not in freq_map: 154 + return "" 155 + 156 + _singular, plural, adjective = freq_map[freq] 157 + 158 + if interval == 1: 159 + desc = adjective 160 + else: 161 + desc = f"Every {interval} {plural}" 162 + 163 + by_day = rrule.get("BYDAY", []) 164 + if by_day: 165 + names = [day_names.get(str(d).lstrip("+-0123456789"), str(d)) for d in by_day] 166 + desc += f" on {', '.join(names)}" 167 + 168 + by_monthday = rrule.get("BYMONTHDAY", []) 169 + if by_monthday: 170 + days_str = ", ".join(str(d) for d in by_monthday) 171 + desc += f" on day {days_str}" 172 + 173 + by_month = rrule.get("BYMONTH", []) 174 + if by_month: 175 + month_names = { 176 + 1: "Jan", 177 + 2: "Feb", 178 + 3: "Mar", 179 + 4: "Apr", 180 + 5: "May", 181 + 6: "Jun", 182 + 7: "Jul", 183 + 8: "Aug", 184 + 9: "Sep", 185 + 10: "Oct", 186 + 11: "Nov", 187 + 12: "Dec", 188 + } 189 + names = [month_names.get(int(m), str(m)) for m in by_month] 190 + desc += f" in {', '.join(names)}" 191 + 192 + count = rrule.get("COUNT", []) 193 + if count: 194 + desc += f", {int(count[0])} times" 195 + 196 + until = rrule.get("UNTIL", []) 197 + if until: 198 + until_val = until[0] 199 + if hasattr(until_val, "strftime"): 200 + desc += f", until {until_val.strftime('%Y-%m-%d')}" 201 + 202 + return desc 203 + 204 + 129 205 def _render_event_markdown(event: dict[str, Any]) -> str: 130 206 """Render a calendar event as markdown.""" 131 207 title = event.get("title", "Untitled event") ··· 148 224 except ValueError: 149 225 pass 150 226 227 + recurrence = event.get("recurrence", "") 228 + if recurrence: 229 + lines.append(f"🔁 {recurrence}") 230 + 151 231 location = event.get("location", "") 152 232 if location: 153 233 lines.append(f"📍 {location}") ··· 226 306 attendees.append(parsed) 227 307 seen_emails.add(parsed["email"]) 228 308 309 + # Recurrence rule 310 + rrule = component.get("RRULE") 311 + recurrence = "" 312 + if rrule: 313 + recurrence = _describe_rrule(dict(rrule)) 314 + 229 315 # Build base entry 230 316 entry: dict[str, Any] = { 231 317 "type": "calendar_event", ··· 243 329 entry["location"] = location 244 330 if attendees: 245 331 entry["attendees"] = attendees 332 + if recurrence: 333 + entry["recurrence"] = recurrence 246 334 247 335 entries.append(entry) 248 336 ··· 357 445 all_entries.sort(key=lambda entry: entry["create_ts"]) 358 446 359 447 windows = window_items(all_entries, "create_ts") 360 - created_files: list[str] = [] 361 - segments: list[tuple[str, str]] = [] 362 - 363 - for day, seg_key, window_events in windows: 364 - segment_dir = day_path(day) / "import.ics" / seg_key 365 - segment_dir.mkdir(parents=True, exist_ok=True) 366 - md_path = segment_dir / "imported.md" 367 - markdown = "\n\n".join( 368 - _render_event_markdown(event) for event in window_events 369 - ) 370 - md_path.write_text(markdown + "\n", encoding="utf-8") 371 - created_files.append(str(md_path)) 372 - segments.append((day, seg_key)) 448 + created_files, segments = write_markdown_segments( 449 + "ics", 450 + windows, 451 + lambda items: "\n\n".join(_render_event_markdown(e) for e in items), 452 + ) 373 453 374 454 segment_days = {day for day, _ in segments} 375 455
+10 -13
think/importers/kindle.py
··· 10 10 from typing import Callable 11 11 12 12 from think.importers.file_importer import ImportPreview, ImportResult 13 - from think.importers.shared import seed_entities, window_items 14 - from think.utils import day_path 13 + from think.importers.shared import ( 14 + seed_entities, 15 + window_items, 16 + write_markdown_segments, 17 + ) 15 18 16 19 logger = logging.getLogger(__name__) 17 20 ··· 291 294 entries.sort(key=lambda e: e["create_ts"]) 292 295 293 296 windows = window_items(entries, "create_ts", tz=None) 294 - created_files: list[str] = [] 295 - segments: list[tuple[str, str]] = [] 296 - 297 - for day, seg_key, window_highlights in windows: 298 - segment_dir = day_path(day) / "import.kindle" / seg_key 299 - segment_dir.mkdir(parents=True, exist_ok=True) 300 - md_path = segment_dir / "imported.md" 301 - markdown = _render_highlight_markdown(window_highlights) 302 - md_path.write_text(markdown + "\n", encoding="utf-8") 303 - created_files.append(str(md_path)) 304 - segments.append((day, seg_key)) 297 + created_files, segments = write_markdown_segments( 298 + "kindle", 299 + windows, 300 + _render_highlight_markdown, 301 + ) 305 302 306 303 segment_days = {day for day, _ in segments} 307 304
+7 -58
think/importers/obsidian.py
··· 11 11 from typing import Any, Callable 12 12 13 13 from think.importers.file_importer import ImportPreview, ImportResult 14 - from think.importers.shared import seed_entities 15 - from think.utils import day_path 14 + from think.importers.shared import seed_entities, window_items, write_markdown_segments 16 15 17 16 logger = logging.getLogger(__name__) 18 17 ··· 120 119 return name.startswith(".") 121 120 122 121 123 - def _window_notes( 124 - notes: list[dict[str, Any]], 125 - window_duration: int = 300, 126 - ) -> list[tuple[str, str, list[dict[str, Any]]]]: 127 - """Group sorted notes into fixed-duration windows by mtime.""" 128 - if not notes: 129 - return [] 130 - 131 - windows: list[tuple[str, str, list[dict[str, Any]]]] = [] 132 - window_start: float | None = None 133 - window_day: str | None = None 134 - window_notes: list[dict[str, Any]] = [] 135 - 136 - for note in notes: 137 - mtime = note["mtime"] 138 - note_dt = dt.datetime.fromtimestamp(mtime) 139 - note_day = note_dt.strftime("%Y%m%d") 140 - 141 - if ( 142 - window_start is None 143 - or note_day != window_day 144 - or mtime - window_start >= window_duration 145 - ): 146 - if window_notes and window_day and window_start is not None: 147 - start_dt = dt.datetime.fromtimestamp(window_start) 148 - seg_key = f"{start_dt.strftime('%H%M%S')}_{window_duration}" 149 - windows.append((window_day, seg_key, window_notes)) 150 - 151 - window_start = mtime 152 - window_day = note_day 153 - window_notes = [] 154 - 155 - window_notes.append(note) 156 - 157 - if window_notes and window_day and window_start is not None: 158 - start_dt = dt.datetime.fromtimestamp(window_start) 159 - seg_key = f"{start_dt.strftime('%H%M%S')}_{window_duration}" 160 - windows.append((window_day, seg_key, window_notes)) 161 - 162 - return windows 163 - 164 - 165 122 def _render_note_markdown(note: dict[str, Any]) -> str: 166 123 """Render a note as markdown for imported.md output.""" 167 124 title = note.get("title", "Untitled") ··· 319 276 320 277 notes.sort(key=lambda n: n["mtime"]) 321 278 322 - windows = _window_notes(notes) 323 - created_files: list[str] = [] 324 - segments: list[tuple[str, str]] = [] 325 - 326 - for day, seg_key, window_notes_list in windows: 327 - segment_dir = day_path(day) / "import.obsidian" / seg_key 328 - segment_dir.mkdir(parents=True, exist_ok=True) 329 - md_path = segment_dir / "imported.md" 330 - markdown = "\n\n".join( 331 - _render_note_markdown(note) for note in window_notes_list 332 - ) 333 - md_path.write_text(markdown + "\n", encoding="utf-8") 334 - created_files.append(str(md_path)) 335 - segments.append((day, seg_key)) 279 + windows = window_items(notes, "mtime", tz=None) 280 + created_files, segments = write_markdown_segments( 281 + "obsidian", 282 + windows, 283 + lambda items: "\n\n".join(_render_note_markdown(n) for n in items), 284 + ) 336 285 337 286 # Seed entities from wikilinks 338 287 entities_seeded = 0
+36 -1
think/importers/shared.py
··· 10 10 import os 11 11 import shutil 12 12 from pathlib import Path 13 - from typing import TYPE_CHECKING, Any 13 + from typing import TYPE_CHECKING, Any, Callable 14 14 15 15 from think.importers.utils import save_import_file, write_import_metadata 16 16 from think.utils import day_path, get_journal, now_ms ··· 239 239 windows.append((window_day, seg_key, window_items_acc)) 240 240 241 241 return windows 242 + 243 + 244 + def write_markdown_segments( 245 + source: str, 246 + windows: list[tuple[str, str, list[dict[str, Any]]]], 247 + render: Callable[[list[dict[str, Any]]], str], 248 + ) -> tuple[list[str], list[tuple[str, str]]]: 249 + """Write markdown segments from windowed items. 250 + 251 + Parameters 252 + ---------- 253 + source : str 254 + Import source name (used in path: ``import.{source}``). 255 + windows : list 256 + Output of ``window_items`` — (day, seg_key, items) tuples. 257 + render : callable 258 + Function taking list of items and returning markdown string. 259 + 260 + Returns 261 + ------- 262 + tuple[list[str], list[tuple[str, str]]] 263 + (created_file_paths, segment_tuples) 264 + """ 265 + created_files: list[str] = [] 266 + segments: list[tuple[str, str]] = [] 267 + 268 + for day, seg_key, items in windows: 269 + segment_dir = day_path(day) / f"import.{source}" / seg_key 270 + segment_dir.mkdir(parents=True, exist_ok=True) 271 + md_path = segment_dir / "imported.md" 272 + md_path.write_text(render(items) + "\n", encoding="utf-8") 273 + created_files.append(str(md_path)) 274 + segments.append((day, seg_key)) 275 + 276 + return created_files, segments 242 277 243 278 244 279 # MIME type mapping for import metadata