opencode-semantic-memory 0.1.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.
@@ -0,0 +1,879 @@
1
+ """Background ingestion daemon for automatic file and database monitoring."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import logging
6
+ import os
7
+ import sqlite3
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from contextlib import asynccontextmanager
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any, Callable, TypeVar
13
+
14
+ from opencode_memory.config import Config
15
+ from opencode_memory.enrichment.gitlab import GitLabEnricher
16
+ from opencode_memory.ingestion.embeddings import EmbeddingEngine
17
+ from opencode_memory.ingestion.opencode_db import OpenCodeDBObserver
18
+ from opencode_memory.ingestion.parser import MarkdownParser
19
+ from opencode_memory.ingestion.watcher import FileWatcher
20
+ from opencode_memory.linking.linker import MemoryLinker
21
+ from opencode_memory.models import Entity, LinkType, MemoryLink
22
+ from opencode_memory.storage.sqlite import SQLiteStorage
23
+ from opencode_memory.storage.vectors import VectorStorage
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ T = TypeVar("T")
28
+
29
+ _db_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_")
30
+
31
+
32
+ async def run_in_thread(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
33
+ """Run a blocking function in a thread pool to avoid blocking the event loop."""
34
+ loop = asyncio.get_event_loop()
35
+ return await loop.run_in_executor(_db_executor, functools.partial(func, *args, **kwargs))
36
+
37
+
38
+ class BackgroundThrottle:
39
+ """Throttle background work when MCP requests are active.
40
+
41
+ MCP request handlers call `priority_context()` to signal they need CPU.
42
+ Background tasks call `yield_to_priority()` between work units to pause
43
+ if priority work is waiting.
44
+ """
45
+
46
+ def __init__(self):
47
+ self._priority_count = 0
48
+ self._lock = asyncio.Lock()
49
+ self._idle = asyncio.Event()
50
+ self._idle.set() # Start idle
51
+
52
+ @asynccontextmanager
53
+ async def priority_context(self):
54
+ """Context manager for priority (MCP) work."""
55
+ async with self._lock:
56
+ self._priority_count += 1
57
+ self._idle.clear()
58
+ try:
59
+ yield
60
+ finally:
61
+ async with self._lock:
62
+ self._priority_count -= 1
63
+ if self._priority_count == 0:
64
+ self._idle.set()
65
+
66
+ async def yield_to_priority(self, min_delay: float = 0.1):
67
+ """Yield CPU to priority work if any is waiting."""
68
+ # Always yield briefly to let event loop process other tasks
69
+ await asyncio.sleep(min_delay)
70
+ # If priority work is active, wait for it to finish
71
+ if not self._idle.is_set():
72
+ logger.debug("Background work pausing for priority requests")
73
+ await self._idle.wait()
74
+ await asyncio.sleep(min_delay) # Brief pause after priority work
75
+
76
+
77
+ # Global throttle instance - used by daemon and can be imported by server
78
+ background_throttle = BackgroundThrottle()
79
+
80
+
81
+ CATCHUP_BATCH_SIZE = 50
82
+ CATCHUP_DELAY_SECONDS = 2.0
83
+ EMBEDDING_BATCH_SIZE = 16 # Max texts to embed at once (keeps CPU bursts short)
84
+ RESCAN_RECENT_HOURS = 6
85
+ RESCAN_INTERVAL_MINUTES = 10
86
+ ENRICHMENT_INTERVAL_MINUTES = 5
87
+ ENRICHMENT_BATCH_SIZE = 10
88
+ ENRICHMENT_STALE_HOURS = 24
89
+ CLEANUP_INTERVAL_HOURS = 1 # Hourly
90
+ CLEANUP_RESOLVED_BLOCKERS_DAYS = 90
91
+ CLEANUP_CONVERSATIONS_DAYS = 180
92
+ VECTOR_KEEP_VERSIONS = 10 # Keep only last 10 LanceDB versions
93
+ LINKING_INTERVAL_MINUTES = 5 # Run linking every 5 minutes (was 15)
94
+ LINKING_BATCH_SIZE = 100 # Process up to 100 memories per pass (was 30)
95
+ EXTRACTION_INTERVAL_HOURS = 6 # Run knowledge extraction every 6 hours
96
+ EXTRACTION_SINCE_DAYS = 30 # Look at conversations from last 30 days
97
+
98
+
99
+ class IngestionDaemon:
100
+ """Background daemon for automatic ingestion from files and OpenCode database."""
101
+
102
+ def __init__(
103
+ self,
104
+ config: Config,
105
+ sqlite: SQLiteStorage,
106
+ vectors: VectorStorage,
107
+ embeddings: EmbeddingEngine,
108
+ ):
109
+ self.config = config
110
+ self.sqlite = sqlite
111
+ self.vectors = vectors
112
+ self.embeddings = embeddings
113
+ self.parser = MarkdownParser()
114
+
115
+ self._file_watcher: FileWatcher | None = None
116
+ self._db_observer: OpenCodeDBObserver | None = None
117
+ self._enricher: GitLabEnricher | None = None
118
+ self._linker: MemoryLinker | None = None
119
+ self._poll_task: asyncio.Task | None = None
120
+ self._catchup_task: asyncio.Task | None = None
121
+ self._rescan_task: asyncio.Task | None = None
122
+ self._enrichment_task: asyncio.Task | None = None
123
+ self._cleanup_task: asyncio.Task | None = None
124
+ self._linking_task: asyncio.Task | None = None
125
+ self._extraction_task: asyncio.Task | None = None
126
+ self._running = False
127
+ self._loop: asyncio.AbstractEventLoop | None = None
128
+
129
+ async def _embed_batch_throttled(self, texts: list[str]) -> list[list[float]]:
130
+ """Embed texts one at a time, yielding to priority between each."""
131
+ all_embeddings: list[list[float]] = []
132
+ for text in texts:
133
+ # Check priority BEFORE starting embedding (which blocks GIL)
134
+ await background_throttle.yield_to_priority(min_delay=0.05)
135
+ embedding = await self.embeddings.embed_async(text)
136
+ all_embeddings.append(embedding)
137
+ # Also yield after to let event loop process responses
138
+ await asyncio.sleep(0)
139
+ return all_embeddings
140
+
141
+ def _queue_file_ingest(self, path: Path) -> None:
142
+ """Queue a file for async ingestion (called by watcher from separate thread)."""
143
+ if self._loop is None:
144
+ logger.warning(f"Event loop not set, cannot queue file: {path}")
145
+ return
146
+ asyncio.run_coroutine_threadsafe(self._ingest_file_async(path), self._loop)
147
+
148
+ async def _ingest_file_async(self, path: Path) -> None:
149
+ """Ingest a single file asynchronously."""
150
+ if not path.exists() or not path.is_file():
151
+ logger.warning(f"File not found or not a file: {path}")
152
+ return
153
+
154
+ try:
155
+ logger.info(f"Ingesting file: {path}")
156
+ # Run file parsing in thread to avoid blocking event loop
157
+ doc = await run_in_thread(self.parser.parse_file, path)
158
+
159
+ entity_ids = []
160
+ for entity_type, ref in doc.entities:
161
+ entity = Entity(type=entity_type, ref=ref)
162
+ entity_id = self.sqlite.upsert_entity(entity)
163
+ entity_ids.append(entity_id)
164
+
165
+ for memory in doc.memories:
166
+ memory_id = self.sqlite.insert_memory(memory, entity_ids)
167
+ embedding = await self.embeddings.embed_async(memory.embedding_content())
168
+ self.vectors.add(
169
+ f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding
170
+ )
171
+
172
+ logger.info(
173
+ f"Ingested {path}: {len(doc.entities)} entities, {len(doc.memories)} memories"
174
+ )
175
+ except Exception as e:
176
+ logger.exception(f"Error ingesting file {path}: {e}")
177
+
178
+ async def poll_opencode_db(self) -> None:
179
+ """Poll OpenCode DB for new sessions (called periodically)."""
180
+ if self._db_observer is None:
181
+ return
182
+
183
+ # Skip if OpenCode database doesn't exist yet (normal for fresh installs)
184
+ if not self._db_observer.db_exists():
185
+ return
186
+
187
+ try:
188
+ state = self.sqlite.get_ingest_state("opencode_db")
189
+ since = None
190
+ if state and state.get("last_processed"):
191
+ since = datetime.fromisoformat(state["last_processed"])
192
+
193
+ # Run blocking poll in thread pool
194
+ memories = await run_in_thread(self._db_observer.poll_new_content, since)
195
+
196
+ if memories:
197
+ await background_throttle.yield_to_priority()
198
+
199
+ # Batch embed all memories
200
+ texts = [m.embedding_content() for m in memories]
201
+ embeddings = await self._embed_batch_throttled(texts)
202
+
203
+ for memory, embedding in zip(memories, embeddings):
204
+ memory_id = self.sqlite.insert_memory(memory)
205
+ self.vectors.add(
206
+ f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding
207
+ )
208
+
209
+ logger.info(f"Ingested {len(memories)} sessions from OpenCode database")
210
+ self.sqlite.set_ingest_state("opencode_db", datetime.now(UTC).isoformat())
211
+ except FileNotFoundError:
212
+ # Database doesn't exist yet - this is fine, skip silently
213
+ pass
214
+ except Exception as e:
215
+ logger.exception(f"Error polling OpenCode database: {e}")
216
+
217
+ async def _poll_loop(self) -> None:
218
+ """Background loop for DB polling."""
219
+ await asyncio.sleep(15) # Wait 15s before first poll (ProcessPool avoids GIL)
220
+ interval = self.config.ingestion.db_poll_interval
221
+ while self._running:
222
+ await background_throttle.yield_to_priority()
223
+ await self.poll_opencode_db()
224
+ await asyncio.sleep(interval)
225
+
226
+ async def _enrichment_loop(self) -> None:
227
+ """Periodically enrich entities with GitLab metadata using tiered scheduling.
228
+
229
+ Entities are refreshed based on activity level:
230
+ - Hot (mentioned in last 24h): refresh hourly
231
+ - Warm (mentioned in last 7 days): refresh every 6 hours
232
+ - Cool (mentioned in last 30 days): refresh daily
233
+ - Cold (older): refresh weekly
234
+ """
235
+ if not self._enricher:
236
+ return
237
+
238
+ await asyncio.sleep(30)
239
+
240
+ while self._running:
241
+ try:
242
+ await background_throttle.yield_to_priority()
243
+
244
+ # Get entities due for refresh based on tiered scheduling
245
+ entities_to_refresh = self.sqlite.get_entities_for_refresh(
246
+ limit=ENRICHMENT_BATCH_SIZE,
247
+ )
248
+
249
+ if entities_to_refresh:
250
+ logger.debug(f"Enriching {len(entities_to_refresh)} entities (tiered refresh)")
251
+ enriched_count = 0
252
+
253
+ for entity in entities_to_refresh:
254
+ if not self._running:
255
+ break
256
+
257
+ try:
258
+ old_state = entity.metadata.get("state") if entity.metadata else None
259
+ enriched = await self._enricher.enrich_entity(entity)
260
+
261
+ if enriched.title or enriched.metadata:
262
+ self.sqlite.upsert_entity(enriched)
263
+ # Mark as enriched to update last_enriched_at
264
+ if entity.id:
265
+ self.sqlite.update_entity_enriched(entity.id)
266
+ enriched_count += 1
267
+
268
+ if enriched.metadata:
269
+ new_state = enriched.metadata.get("state")
270
+
271
+ # State changed - bump to hot for continued monitoring
272
+ if old_state != new_state and entity.id:
273
+ self.sqlite.bump_entity_mention(entity.id)
274
+ logger.debug(
275
+ f"Entity {entity.ref} state changed: "
276
+ f"{old_state} -> {new_state}"
277
+ )
278
+
279
+ # Auto-resolve blockers for closed entities
280
+ if new_state in ("merged", "closed"):
281
+ resolved = self.sqlite.resolve_blockers_for_entity(
282
+ entity.ref
283
+ )
284
+ if resolved > 0:
285
+ logger.info(
286
+ f"Auto-resolved {resolved} blockers for "
287
+ f"{new_state} entity {entity.ref}"
288
+ )
289
+ except Exception as e:
290
+ logger.debug(f"Failed to enrich {entity.ref}: {e}")
291
+
292
+ await asyncio.sleep(0.5)
293
+
294
+ if enriched_count > 0:
295
+ logger.info(f"Enriched {enriched_count} entities with GitLab metadata")
296
+
297
+ except Exception as e:
298
+ logger.exception(f"Error in enrichment loop: {e}")
299
+
300
+ # Run more frequently since tiered scheduling handles the intervals
301
+ await asyncio.sleep(ENRICHMENT_INTERVAL_MINUTES * 60)
302
+
303
+ async def _cleanup_loop(self) -> None:
304
+ """Periodically archive old/expired memories and cleanup vector storage."""
305
+ # Initial delay before first cleanup (5 min)
306
+ await asyncio.sleep(300)
307
+
308
+ while self._running:
309
+ try:
310
+ await background_throttle.yield_to_priority()
311
+
312
+ # Memory cleanup - yield between operations to allow MCP requests through
313
+ archived_expired = self.sqlite.archive_expired_memories()
314
+ if archived_expired > 0:
315
+ logger.info(f"Archived {archived_expired} expired memories")
316
+
317
+ await background_throttle.yield_to_priority(min_delay=0.05)
318
+
319
+ archived_blockers = self.sqlite.archive_old_resolved_blockers(
320
+ days_old=CLEANUP_RESOLVED_BLOCKERS_DAYS
321
+ )
322
+ if archived_blockers > 0:
323
+ logger.info(f"Archived {archived_blockers} old resolved blockers")
324
+
325
+ await background_throttle.yield_to_priority(min_delay=0.05)
326
+
327
+ archived_conversations = self.sqlite.archive_old_conversations(
328
+ days_old=CLEANUP_CONVERSATIONS_DAYS
329
+ )
330
+ if archived_conversations > 0:
331
+ logger.info(f"Archived {archived_conversations} old conversations")
332
+
333
+ total = archived_expired + archived_blockers + archived_conversations
334
+ if total > 0:
335
+ logger.info(f"Cleanup complete: archived {total} memories total")
336
+
337
+ await background_throttle.yield_to_priority(min_delay=0.05)
338
+
339
+ # Vector storage cleanup - keep only recent versions
340
+ vector_stats = await run_in_thread(
341
+ self.vectors.cleanup_old_versions, VECTOR_KEEP_VERSIONS
342
+ )
343
+ if vector_stats.get("status") == "success":
344
+ removed = vector_stats.get("versions_removed", 0)
345
+ if removed > 0:
346
+ logger.info(
347
+ f"Vector cleanup: removed {removed} old versions, "
348
+ f"freed {vector_stats.get('bytes_freed', 0) / 1024 / 1024:.1f} MB"
349
+ )
350
+ elif vector_stats.get("status") == "error":
351
+ logger.warning(f"Vector cleanup failed: {vector_stats.get('error')}")
352
+
353
+ except Exception as e:
354
+ logger.exception(f"Error in cleanup loop: {e}")
355
+
356
+ await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
357
+
358
+ async def _linking_loop(self) -> None:
359
+ """Periodically discover and create links between memories."""
360
+ # Initial delay before starting
361
+ await asyncio.sleep(120)
362
+
363
+ while self._running:
364
+ try:
365
+ if self._linker:
366
+ await background_throttle.yield_to_priority()
367
+
368
+ stats = await self._linker.run_linking_pass(limit=LINKING_BATCH_SIZE)
369
+
370
+ if stats.get("links_created", 0) > 0:
371
+ logger.info(
372
+ f"Memory linking: processed {stats['processed']} memories, "
373
+ f"created {stats['links_created']} links"
374
+ )
375
+
376
+ except Exception as e:
377
+ logger.exception(f"Error in linking loop: {e}")
378
+
379
+ await asyncio.sleep(LINKING_INTERVAL_MINUTES * 60)
380
+
381
+ async def _extraction_loop(self) -> None:
382
+ """Periodically extract knowledge from conversations using LLM.
383
+
384
+ Processes one conversation at a time, commits immediately,
385
+ to avoid memory runaway with large batches.
386
+ """
387
+ from opencode_memory.extraction import (
388
+ extract_knowledge_from_conversation,
389
+ get_unprocessed_conversations,
390
+ _find_opencode,
391
+ )
392
+
393
+ # Initial delay - let other tasks settle first
394
+ await asyncio.sleep(600) # 10 minutes
395
+
396
+ # Check if opencode is available
397
+ if not _find_opencode():
398
+ logger.warning("Knowledge extraction disabled: opencode not found")
399
+ return
400
+
401
+ logger.info("Knowledge extraction task started (runs every 6 hours)")
402
+
403
+ while self._running:
404
+ try:
405
+ await background_throttle.yield_to_priority()
406
+
407
+ # Get unprocessed conversations
408
+ conversations = get_unprocessed_conversations(
409
+ self.sqlite,
410
+ since_days=EXTRACTION_SINCE_DAYS,
411
+ limit=50, # Process up to 50 per cycle
412
+ )
413
+
414
+ if not conversations:
415
+ logger.debug("No conversations to extract knowledge from")
416
+ else:
417
+ logger.info(
418
+ f"Knowledge extraction: {len(conversations)} conversations to process"
419
+ )
420
+ total_extracted = 0
421
+
422
+ for conv in conversations:
423
+ if not self._running:
424
+ break
425
+
426
+ await background_throttle.yield_to_priority()
427
+
428
+ conv_id = conv["id"]
429
+ content = conv["content"]
430
+ title = conv.get("what", "Untitled")[:50]
431
+
432
+ logger.info(f"Extracting from: {title}...")
433
+
434
+ count = await extract_knowledge_from_conversation(
435
+ conv_id=conv_id,
436
+ content=content,
437
+ project=conv.get("project"),
438
+ source_file=conv.get("source_file"),
439
+ sqlite=self.sqlite,
440
+ embeddings=self.embeddings,
441
+ vectors=self.vectors,
442
+ working_directory=self.config.ingestion.working_directory,
443
+ )
444
+
445
+ total_extracted += count
446
+
447
+ # Small delay between conversations to avoid overwhelming
448
+ await asyncio.sleep(5)
449
+
450
+ if total_extracted > 0:
451
+ logger.info(
452
+ f"Knowledge extraction complete: {total_extracted} items extracted"
453
+ )
454
+
455
+ except Exception as e:
456
+ logger.exception(f"Error in extraction loop: {e}")
457
+
458
+ await asyncio.sleep(EXTRACTION_INTERVAL_HOURS * 3600)
459
+
460
+ async def _rescan_recent_sessions(self, opencode_db_path: Path) -> None:
461
+ """Periodically re-scan recent sessions to catch ongoing conversations."""
462
+ await asyncio.sleep(60)
463
+ observer = OpenCodeDBObserver(opencode_db_path)
464
+
465
+ def _fetch_recent_sessions(cutoff_ms: int) -> list[dict]:
466
+ """Fetch recent sessions from OpenCode DB (runs in thread)."""
467
+ if not opencode_db_path.exists():
468
+ logger.debug(f"OpenCode database not found at {opencode_db_path}, skipping rescan")
469
+ return []
470
+ with sqlite3.connect(f"file:{opencode_db_path}?mode=ro", uri=True) as conn:
471
+ conn.row_factory = sqlite3.Row
472
+ cursor = conn.execute(
473
+ """
474
+ SELECT s.id, s.title, s.time_created,
475
+ MAX(m.time_created) as last_message_time,
476
+ COUNT(m.id) as message_count
477
+ FROM session s
478
+ LEFT JOIN message m ON m.session_id = s.id
479
+ WHERE s.time_created > ? OR m.time_created > ?
480
+ GROUP BY s.id
481
+ ORDER BY COALESCE(MAX(m.time_created), s.time_created) DESC
482
+ """,
483
+ (cutoff_ms, cutoff_ms),
484
+ )
485
+ return [dict(row) for row in cursor.fetchall()]
486
+
487
+ def _fetch_session_by_id(session_id: str) -> dict | None:
488
+ """Fetch a single session by ID (runs in thread)."""
489
+ if not opencode_db_path.exists():
490
+ logger.debug(f"OpenCode database not found at {opencode_db_path}")
491
+ return None
492
+ with sqlite3.connect(f"file:{opencode_db_path}?mode=ro", uri=True) as conn:
493
+ conn.row_factory = sqlite3.Row
494
+ cursor = conn.execute("SELECT * FROM session WHERE id = ?", (session_id,))
495
+ row = cursor.fetchone()
496
+ return dict(row) if row else None
497
+
498
+ while self._running:
499
+ try:
500
+ cutoff_ms = int(
501
+ (datetime.now(UTC) - timedelta(hours=RESCAN_RECENT_HOURS)).timestamp() * 1000
502
+ )
503
+
504
+ recent_sessions = await run_in_thread(_fetch_recent_sessions, cutoff_ms)
505
+
506
+ sessions_to_rescan = []
507
+ for row in recent_sessions:
508
+ session_id = row["id"]
509
+ message_count = row["message_count"] or 0
510
+ current_memory_count = self.sqlite.get_session_memory_count(session_id)
511
+ expected_min_memories = 1 + (message_count // 50)
512
+
513
+ if current_memory_count < expected_min_memories or message_count > 10:
514
+ session = await run_in_thread(_fetch_session_by_id, session_id)
515
+ if session:
516
+ sessions_to_rescan.append(session)
517
+
518
+ if not sessions_to_rescan:
519
+ continue
520
+
521
+ await background_throttle.yield_to_priority()
522
+
523
+ # Delete old memories for sessions being rescanned
524
+ for session in sessions_to_rescan:
525
+ session_id = session["id"]
526
+ self.sqlite.delete_memories_for_session(session_id)
527
+ self.vectors.delete_by_prefix(f"opencode:session:{session_id}")
528
+
529
+ # Extract all memories in batch
530
+ def _extract_batch() -> list[tuple[dict, Any, list]]:
531
+ results = []
532
+ for session in sessions_to_rescan:
533
+ memory = observer.extract_session_summary(session)
534
+ insights = observer.extract_session_insights(session)
535
+ results.append((session, memory, insights))
536
+ return results
537
+
538
+ extractions = await run_in_thread(_extract_batch)
539
+
540
+ # Collect all texts for batch embedding
541
+ memories_to_embed: list[tuple[Any, str]] = []
542
+ for session, memory, insights in extractions:
543
+ if memory:
544
+ memories_to_embed.append((memory, memory.embedding_content()))
545
+ for insight in insights:
546
+ memories_to_embed.append((insight, insight.embedding_content()))
547
+
548
+ if memories_to_embed:
549
+ await background_throttle.yield_to_priority()
550
+ texts = [content for _, content in memories_to_embed]
551
+ embeddings = await self._embed_batch_throttled(texts)
552
+
553
+ for (memory, content), embedding in zip(memories_to_embed, embeddings):
554
+ memory_id = self.sqlite.insert_memory(memory)
555
+ self.vectors.add(f"mem_{memory_id}", memory_id, content, embedding)
556
+
557
+ if sessions_to_rescan:
558
+ logger.info(
559
+ f"Rescanned {len(sessions_to_rescan)} recent sessions "
560
+ f"({len(memories_to_embed)} memories)"
561
+ )
562
+
563
+ except Exception as e:
564
+ logger.exception(f"Error rescanning recent sessions: {e}")
565
+
566
+ await asyncio.sleep(RESCAN_INTERVAL_MINUTES * 60)
567
+
568
+ async def _catchup_historical_sessions(self, opencode_db_path: Path) -> None:
569
+ """Ingest any sessions not yet in memory (runs deprioritized in background)."""
570
+ await asyncio.sleep(30) # Wait 30s before heavy work (ProcessPool avoids GIL)
571
+
572
+ # Skip if OpenCode database doesn't exist yet
573
+ if not opencode_db_path.exists():
574
+ logger.debug("OpenCode database not found, skipping historical catchup")
575
+ return
576
+
577
+ def _fetch_all_session_ids() -> set[str]:
578
+ """Fetch all session IDs from OpenCode DB (runs in thread)."""
579
+ if not opencode_db_path.exists():
580
+ logger.debug(f"OpenCode database not found at {opencode_db_path}, skipping catchup")
581
+ return set()
582
+ with sqlite3.connect(f"file:{opencode_db_path}?mode=ro", uri=True) as conn:
583
+ conn.row_factory = sqlite3.Row
584
+ cursor = conn.execute("SELECT id FROM session ORDER BY time_created ASC")
585
+ return {row["id"] for row in cursor.fetchall()}
586
+
587
+ def _fetch_sessions_batch(session_ids: list[str]) -> list[dict]:
588
+ """Fetch multiple sessions by ID (runs in thread)."""
589
+ if not opencode_db_path.exists():
590
+ logger.debug(f"OpenCode database not found at {opencode_db_path}")
591
+ return []
592
+ with sqlite3.connect(f"file:{opencode_db_path}?mode=ro", uri=True) as conn:
593
+ conn.row_factory = sqlite3.Row
594
+ results = []
595
+ for sid in session_ids:
596
+ cursor = conn.execute("SELECT * FROM session WHERE id = ?", (sid,))
597
+ row = cursor.fetchone()
598
+ if row:
599
+ results.append(dict(row))
600
+ return results
601
+
602
+ def _extract_memories_batch(
603
+ observer: OpenCodeDBObserver, sessions: list[dict]
604
+ ) -> list[tuple[Any, Any, list]]:
605
+ """Extract full conversation, summary, and insights for a batch of sessions.
606
+
607
+ Returns list of (full_memory, summary_memory, insights) tuples.
608
+ """
609
+ results = []
610
+ for session in sessions:
611
+ full_memory, summary_memory = observer.extract_session_memories(session)
612
+ insights = observer.extract_session_insights(session)
613
+ results.append((full_memory, summary_memory, insights))
614
+ return results
615
+
616
+ try:
617
+ all_session_ids = await run_in_thread(_fetch_all_session_ids)
618
+
619
+ ingested_sources = self.sqlite.get_ingested_session_ids()
620
+ ingested_ids = {
621
+ src.replace("opencode:session:", "")
622
+ for src in ingested_sources
623
+ if src.startswith("opencode:session:")
624
+ }
625
+
626
+ missing_ids = list(all_session_ids - ingested_ids)
627
+ if not missing_ids:
628
+ logger.info("Historical catchup: all sessions already ingested")
629
+ return
630
+
631
+ logger.info(f"Historical catchup: {len(missing_ids)} sessions to ingest")
632
+
633
+ observer = OpenCodeDBObserver(opencode_db_path)
634
+ processed = 0
635
+
636
+ # Process in batches for efficient embedding
637
+ for batch_start in range(0, len(missing_ids), CATCHUP_BATCH_SIZE):
638
+ if not self._running:
639
+ logger.info("Historical catchup: stopped early (daemon shutting down)")
640
+ return
641
+
642
+ await background_throttle.yield_to_priority()
643
+
644
+ batch_ids = missing_ids[batch_start : batch_start + CATCHUP_BATCH_SIZE]
645
+
646
+ # Fetch sessions in batch
647
+ sessions = await run_in_thread(_fetch_sessions_batch, batch_ids)
648
+ if not sessions:
649
+ continue
650
+
651
+ # Extract memories in batch (blocking but in thread)
652
+ extractions = await run_in_thread(_extract_memories_batch, observer, sessions)
653
+
654
+ # Collect all texts for batch embedding
655
+ # Track pairs of (full, summary) for linking after storage
656
+ memories_to_embed: list[tuple[Any, str]] = [] # (memory, content)
657
+ link_pairs: list[
658
+ tuple[int, int]
659
+ ] = [] # (full_idx, summary_idx) in memories_to_embed
660
+
661
+ for full_memory, summary_memory, insights in extractions:
662
+ full_idx = None
663
+ summary_idx = None
664
+
665
+ if full_memory:
666
+ full_idx = len(memories_to_embed)
667
+ memories_to_embed.append((full_memory, full_memory.embedding_content()))
668
+ if summary_memory:
669
+ summary_idx = len(memories_to_embed)
670
+ memories_to_embed.append(
671
+ (summary_memory, summary_memory.embedding_content())
672
+ )
673
+
674
+ if full_idx is not None and summary_idx is not None:
675
+ link_pairs.append((full_idx, summary_idx))
676
+
677
+ for insight in insights:
678
+ memories_to_embed.append((insight, insight.embedding_content()))
679
+
680
+ if not memories_to_embed:
681
+ processed += len(sessions)
682
+ continue
683
+
684
+ # Batch embed all at once
685
+ await background_throttle.yield_to_priority()
686
+ texts = [content for _, content in memories_to_embed]
687
+ embeddings = await self._embed_batch_throttled(texts)
688
+
689
+ # Store with embeddings, track IDs for linking
690
+ stored_ids: list[int] = []
691
+ for (memory, content), embedding in zip(memories_to_embed, embeddings):
692
+ memory_id = self.sqlite.insert_memory(memory)
693
+ self.vectors.add(f"mem_{memory_id}", memory_id, content, embedding)
694
+ stored_ids.append(memory_id)
695
+
696
+ # Create links between full conversations and their summaries
697
+ for full_idx, summary_idx in link_pairs:
698
+ full_id = stored_ids[full_idx]
699
+ summary_id = stored_ids[summary_idx]
700
+ link = MemoryLink(
701
+ source_memory_id=full_id,
702
+ target_memory_id=summary_id,
703
+ link_type=LinkType.EXTENDS,
704
+ strength=1.0,
705
+ reason="Summary of full conversation",
706
+ )
707
+ self.sqlite.insert_link(link)
708
+
709
+ processed += len(sessions)
710
+ logger.info(
711
+ f"Historical catchup: {processed}/{len(missing_ids)} sessions "
712
+ f"({len(memories_to_embed)} memories)"
713
+ )
714
+ await background_throttle.yield_to_priority(min_delay=CATCHUP_DELAY_SECONDS)
715
+
716
+ logger.info(f"Historical catchup complete: {processed} sessions ingested")
717
+
718
+ except Exception as e:
719
+ logger.exception(f"Error during historical catchup: {e}")
720
+
721
+ async def start(self) -> None:
722
+ """Start file watcher and DB polling in background."""
723
+ if self._running:
724
+ logger.warning("Daemon already running")
725
+ return
726
+
727
+ self._running = True
728
+ logger.info("Starting ingestion daemon")
729
+
730
+ # Store the event loop for queuing from the file watcher thread
731
+ self._loop = asyncio.get_event_loop()
732
+
733
+ # File watcher queues to the event loop to avoid blocking
734
+ self._file_watcher = FileWatcher(self._queue_file_ingest)
735
+ opencode_dir = Path.home() / ".opencode"
736
+ if opencode_dir.exists():
737
+ self._file_watcher.add_watch(opencode_dir)
738
+ else:
739
+ logger.warning(f"OpenCode directory not found: {opencode_dir}")
740
+
741
+ # Find OpenCode database from watch_paths config
742
+ opencode_db_path: Path | None = None
743
+ for watch_path in self.config.ingestion.watch_paths:
744
+ path = Path(watch_path).expanduser()
745
+ if path.exists() and path.is_dir():
746
+ self._file_watcher.add_watch(path)
747
+ elif path.suffix == ".db":
748
+ # This is the OpenCode database path
749
+ opencode_db_path = path
750
+ logger.info(f"OpenCode database configured at: {opencode_db_path}")
751
+
752
+ self._file_watcher.start()
753
+ logger.info("File watcher started")
754
+
755
+ # Start OpenCode DB observer if configured
756
+ if opencode_db_path is not None:
757
+ self._db_observer = OpenCodeDBObserver(opencode_db_path)
758
+ self._poll_task = asyncio.create_task(self._poll_loop())
759
+ # Delay heavy background tasks to avoid blocking during startup
760
+ # These run in background with low priority and check if DB exists
761
+ self._catchup_task = asyncio.create_task(
762
+ self._catchup_historical_sessions(opencode_db_path)
763
+ )
764
+ self._rescan_task = asyncio.create_task(self._rescan_recent_sessions(opencode_db_path))
765
+ if opencode_db_path.exists():
766
+ logger.info(f"OpenCode DB observer started: {opencode_db_path}")
767
+ else:
768
+ logger.info(
769
+ f"OpenCode database not found yet at {opencode_db_path}, will poll when it appears"
770
+ )
771
+ else:
772
+ logger.info("No OpenCode database configured in watch_paths")
773
+
774
+ if os.environ.get("GITLAB_TOKEN"):
775
+ self._enricher = GitLabEnricher()
776
+ self._enrichment_task = asyncio.create_task(self._enrichment_loop())
777
+ logger.info("GitLab entity enrichment enabled")
778
+ else:
779
+ logger.info("No GITLAB_TOKEN found, entity enrichment disabled")
780
+
781
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
782
+ logger.info("Memory cleanup task started (runs hourly)")
783
+
784
+ self._linker = MemoryLinker(self.sqlite, self.vectors, self.embeddings)
785
+ self._linking_task = asyncio.create_task(self._linking_loop())
786
+ logger.info("Memory linking task started (runs every 15 min)")
787
+
788
+ # Knowledge extraction runs in background with long delays
789
+ # Only enable if explicitly configured (uses LLM API, costs money)
790
+ if self.config.ingestion.llm_extraction:
791
+ self._extraction_task = asyncio.create_task(self._extraction_loop())
792
+ logger.warning(
793
+ "LLM knowledge extraction enabled - this will use your LLM API and incur costs"
794
+ )
795
+ else:
796
+ logger.info(
797
+ "LLM knowledge extraction disabled (set llm_extraction=true in config to enable)"
798
+ )
799
+
800
+ async def stop(self) -> None:
801
+ """Stop all background tasks."""
802
+ if not self._running:
803
+ return
804
+
805
+ logger.info("Stopping ingestion daemon")
806
+ self._running = False
807
+
808
+ if self._extraction_task:
809
+ self._extraction_task.cancel()
810
+ try:
811
+ await self._extraction_task
812
+ except asyncio.CancelledError:
813
+ pass
814
+ self._extraction_task = None
815
+
816
+ if self._linking_task:
817
+ self._linking_task.cancel()
818
+ try:
819
+ await self._linking_task
820
+ except asyncio.CancelledError:
821
+ pass
822
+ self._linking_task = None
823
+ self._linker = None
824
+
825
+ if self._cleanup_task:
826
+ self._cleanup_task.cancel()
827
+ try:
828
+ await self._cleanup_task
829
+ except asyncio.CancelledError:
830
+ pass
831
+ self._cleanup_task = None
832
+
833
+ if self._enrichment_task:
834
+ self._enrichment_task.cancel()
835
+ try:
836
+ await self._enrichment_task
837
+ except asyncio.CancelledError:
838
+ pass
839
+ self._enrichment_task = None
840
+
841
+ if self._enricher:
842
+ await self._enricher.close()
843
+ self._enricher = None
844
+
845
+ if self._rescan_task:
846
+ self._rescan_task.cancel()
847
+ try:
848
+ await self._rescan_task
849
+ except asyncio.CancelledError:
850
+ pass
851
+ self._rescan_task = None
852
+
853
+ if self._catchup_task:
854
+ self._catchup_task.cancel()
855
+ try:
856
+ await self._catchup_task
857
+ except asyncio.CancelledError:
858
+ pass
859
+ self._catchup_task = None
860
+
861
+ if self._poll_task:
862
+ self._poll_task.cancel()
863
+ try:
864
+ await self._poll_task
865
+ except asyncio.CancelledError:
866
+ pass
867
+ self._poll_task = None
868
+
869
+ if self._file_watcher:
870
+ self._file_watcher.stop()
871
+ self._file_watcher = None
872
+
873
+ self._db_observer = None
874
+ logger.info("Ingestion daemon stopped")
875
+
876
+ @property
877
+ def is_running(self) -> bool:
878
+ """Check if daemon is running."""
879
+ return self._running