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.
- opencode_memory/__init__.py +3 -0
- opencode_memory/cache.py +261 -0
- opencode_memory/cli.py +794 -0
- opencode_memory/config.py +89 -0
- opencode_memory/daemon.py +879 -0
- opencode_memory/enrichment/__init__.py +0 -0
- opencode_memory/enrichment/gitlab.py +237 -0
- opencode_memory/extraction.py +225 -0
- opencode_memory/historical_ingest.py +142 -0
- opencode_memory/http_server.py +464 -0
- opencode_memory/ingestion/__init__.py +7 -0
- opencode_memory/ingestion/embeddings.py +211 -0
- opencode_memory/ingestion/extractors.py +287 -0
- opencode_memory/ingestion/opencode_db.py +448 -0
- opencode_memory/ingestion/parser.py +344 -0
- opencode_memory/ingestion/watcher.py +88 -0
- opencode_memory/linking/__init__.py +5 -0
- opencode_memory/linking/linker.py +323 -0
- opencode_memory/metrics.py +273 -0
- opencode_memory/models.py +171 -0
- opencode_memory/project.py +86 -0
- opencode_memory/query/__init__.py +5 -0
- opencode_memory/query/hybrid.py +196 -0
- opencode_memory/server.py +2795 -0
- opencode_memory/session/__init__.py +5 -0
- opencode_memory/session/registry.py +57 -0
- opencode_memory/storage/__init__.py +6 -0
- opencode_memory/storage/sqlite.py +1608 -0
- opencode_memory/storage/vectors.py +199 -0
- opencode_semantic_memory-0.1.0.dist-info/METADATA +531 -0
- opencode_semantic_memory-0.1.0.dist-info/RECORD +33 -0
- opencode_semantic_memory-0.1.0.dist-info/WHEEL +4 -0
- opencode_semantic_memory-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|