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.
File without changes
@@ -0,0 +1,237 @@
1
+ """GitLab API client for entity enrichment."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from datetime import UTC, datetime
7
+
8
+ import httpx
9
+
10
+ from opencode_memory.models import Entity, EntityType
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ GITLAB_API_URL = "https://gitlab.com/api/v4"
15
+ DEFAULT_PROJECT = "gitlab-org/gitlab"
16
+
17
+ # Rate limiting: GitLab allows 2000 requests/minute for authenticated users
18
+ # We use a conservative limit to leave headroom for other tools
19
+ RATE_LIMIT_REQUESTS = 30 # requests per window
20
+ RATE_LIMIT_WINDOW = 60.0 # seconds
21
+
22
+
23
+ class GitLabEnricher:
24
+ """Fetch entity metadata from GitLab API."""
25
+
26
+ def __init__(
27
+ self,
28
+ token: str | None = None,
29
+ base_url: str = GITLAB_API_URL,
30
+ default_project: str = DEFAULT_PROJECT,
31
+ rate_limit: int = RATE_LIMIT_REQUESTS,
32
+ rate_window: float = RATE_LIMIT_WINDOW,
33
+ ):
34
+ self.token = token or os.environ.get("GITLAB_TOKEN")
35
+ self.base_url = base_url
36
+ self.default_project = default_project
37
+ self._client: httpx.AsyncClient | None = None
38
+
39
+ # Rate limiting state
40
+ self._rate_limit = rate_limit
41
+ self._rate_window = rate_window
42
+ self._request_times: list[float] = []
43
+ self._rate_lock = asyncio.Lock()
44
+
45
+ async def _get_client(self) -> httpx.AsyncClient:
46
+ if self._client is None:
47
+ headers = {}
48
+ if self.token:
49
+ headers["PRIVATE-TOKEN"] = self.token
50
+ self._client = httpx.AsyncClient(
51
+ base_url=self.base_url,
52
+ headers=headers,
53
+ timeout=30.0,
54
+ )
55
+ return self._client
56
+
57
+ async def close(self) -> None:
58
+ if self._client:
59
+ await self._client.aclose()
60
+ self._client = None
61
+
62
+ async def _wait_for_rate_limit(self) -> None:
63
+ """Wait if necessary to stay within rate limits."""
64
+ async with self._rate_lock:
65
+ now = asyncio.get_event_loop().time()
66
+
67
+ # Remove requests outside the window
68
+ cutoff = now - self._rate_window
69
+ self._request_times = [t for t in self._request_times if t > cutoff]
70
+
71
+ # If at limit, wait for oldest request to expire
72
+ if len(self._request_times) >= self._rate_limit:
73
+ oldest = self._request_times[0]
74
+ wait_time = oldest + self._rate_window - now
75
+ if wait_time > 0:
76
+ logger.debug(f"Rate limit reached, waiting {wait_time:.1f}s")
77
+ await asyncio.sleep(wait_time)
78
+ # Re-filter after sleeping
79
+ now = asyncio.get_event_loop().time()
80
+ cutoff = now - self._rate_window
81
+ self._request_times = [t for t in self._request_times if t > cutoff]
82
+
83
+ # Record this request
84
+ self._request_times.append(now)
85
+
86
+ async def enrich_entity(self, entity: Entity) -> Entity:
87
+ """Fetch metadata for an entity and update it."""
88
+ if not self.token:
89
+ logger.debug("No GitLab token available, skipping enrichment")
90
+ return entity
91
+
92
+ try:
93
+ if entity.type == EntityType.MR:
94
+ return await self._enrich_mr(entity)
95
+ elif entity.type == EntityType.ISSUE:
96
+ return await self._enrich_issue(entity)
97
+ elif entity.type == EntityType.EPIC:
98
+ return await self._enrich_epic(entity)
99
+ elif entity.type == EntityType.PERSON:
100
+ return await self._enrich_user(entity)
101
+ except Exception as e:
102
+ logger.warning(f"Failed to enrich {entity.type.value} {entity.ref}: {e}")
103
+
104
+ return entity
105
+
106
+ def _extract_iid(self, ref: str) -> int | None:
107
+ """Extract IID from ref like !123 or #456."""
108
+ if ref and len(ref) > 1:
109
+ try:
110
+ return int(ref[1:])
111
+ except ValueError:
112
+ return None
113
+ return None
114
+
115
+ def _get_project_path(self, entity: Entity) -> str:
116
+ """Get project path, URL-encoded."""
117
+ project = entity.project or self.default_project
118
+ return project.replace("/", "%2F")
119
+
120
+ async def _enrich_mr(self, entity: Entity) -> Entity:
121
+ """Fetch MR metadata."""
122
+ iid = self._extract_iid(entity.ref)
123
+ if not iid:
124
+ return entity
125
+
126
+ await self._wait_for_rate_limit()
127
+ client = await self._get_client()
128
+ project = self._get_project_path(entity)
129
+ response = await client.get(f"/projects/{project}/merge_requests/{iid}")
130
+
131
+ if response.status_code == 200:
132
+ data = response.json()
133
+ entity.title = data.get("title")
134
+ entity.metadata = {
135
+ "state": data.get("state"),
136
+ "author": data.get("author", {}).get("username"),
137
+ "web_url": data.get("web_url"),
138
+ "labels": data.get("labels", []),
139
+ "draft": data.get("draft", False),
140
+ "merged_at": data.get("merged_at"),
141
+ }
142
+ entity.updated_at = datetime.now(UTC)
143
+ logger.debug(f"Enriched MR {entity.ref}: {entity.title}")
144
+
145
+ return entity
146
+
147
+ async def _enrich_issue(self, entity: Entity) -> Entity:
148
+ """Fetch issue metadata."""
149
+ iid = self._extract_iid(entity.ref)
150
+ if not iid:
151
+ return entity
152
+
153
+ await self._wait_for_rate_limit()
154
+ client = await self._get_client()
155
+ project = self._get_project_path(entity)
156
+ response = await client.get(f"/projects/{project}/issues/{iid}")
157
+
158
+ if response.status_code == 200:
159
+ data = response.json()
160
+ entity.title = data.get("title")
161
+ entity.metadata = {
162
+ "state": data.get("state"),
163
+ "author": data.get("author", {}).get("username"),
164
+ "web_url": data.get("web_url"),
165
+ "labels": data.get("labels", []),
166
+ "assignees": [a.get("username") for a in data.get("assignees", [])],
167
+ "milestone": data.get("milestone", {}).get("title")
168
+ if data.get("milestone")
169
+ else None,
170
+ }
171
+ entity.updated_at = datetime.now(UTC)
172
+ logger.debug(f"Enriched issue {entity.ref}: {entity.title}")
173
+
174
+ return entity
175
+
176
+ async def _enrich_epic(self, entity: Entity) -> Entity:
177
+ """Fetch epic metadata."""
178
+ iid = self._extract_iid(entity.ref)
179
+ if not iid:
180
+ return entity
181
+
182
+ await self._wait_for_rate_limit()
183
+ client = await self._get_client()
184
+ # Extract group from project path if available, fallback to gitlab-org
185
+ group = (
186
+ entity.project.split("/")[0]
187
+ if entity.project and "/" in entity.project
188
+ else "gitlab-org"
189
+ )
190
+ response = await client.get(f"/groups/{group}/epics/{iid}")
191
+
192
+ if response.status_code == 200:
193
+ data = response.json()
194
+ entity.title = data.get("title")
195
+ entity.metadata = {
196
+ "state": data.get("state"),
197
+ "author": data.get("author", {}).get("username"),
198
+ "web_url": data.get("web_url"),
199
+ "labels": data.get("labels", []),
200
+ }
201
+ entity.updated_at = datetime.now(UTC)
202
+ logger.debug(f"Enriched epic {entity.ref}: {entity.title}")
203
+
204
+ return entity
205
+
206
+ async def _enrich_user(self, entity: Entity) -> Entity:
207
+ """Fetch user metadata."""
208
+ username = entity.ref.lstrip("@") if entity.ref else None
209
+ if not username:
210
+ return entity
211
+
212
+ await self._wait_for_rate_limit()
213
+ client = await self._get_client()
214
+ response = await client.get("/users", params={"username": username})
215
+
216
+ if response.status_code == 200:
217
+ users = response.json()
218
+ if users:
219
+ data = users[0]
220
+ entity.title = data.get("name")
221
+ entity.metadata = {
222
+ "username": data.get("username"),
223
+ "web_url": data.get("web_url"),
224
+ "state": data.get("state"),
225
+ }
226
+ entity.updated_at = datetime.now(UTC)
227
+ logger.debug(f"Enriched user {entity.ref}: {entity.title}")
228
+
229
+ return entity
230
+
231
+ async def enrich_entities(self, entities: list[Entity]) -> list[Entity]:
232
+ """Enrich multiple entities, with rate limiting."""
233
+ results = []
234
+ for entity in entities:
235
+ enriched = await self.enrich_entity(entity)
236
+ results.append(enriched)
237
+ return results
@@ -0,0 +1,225 @@
1
+ """LLM-based knowledge extraction from conversations."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import shutil
7
+ from datetime import UTC, datetime, timedelta
8
+ from pathlib import Path
9
+
10
+ from opencode_memory.ingestion.embeddings import EmbeddingEngine
11
+ from opencode_memory.models import LinkType, Memory, MemoryCategory, MemoryLink
12
+ from opencode_memory.storage.sqlite import SQLiteStorage
13
+ from opencode_memory.storage.vectors import VectorStorage
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ EXTRACTION_PROMPT = """Analyze this conversation and extract valuable knowledge that should be remembered for future sessions.
18
+
19
+ Extract ONLY high-value, reusable knowledge. Be selective - not every conversation has extractable knowledge.
20
+
21
+ For each piece of knowledge, output a JSON object on its own line with these fields:
22
+ - category: one of "procedure", "directive", "decision", "fact"
23
+ - content: the knowledge itself (clear, actionable, standalone)
24
+ - what: brief summary (5-10 words)
25
+ - why: why this matters or context
26
+ - learned: key takeaway for future
27
+
28
+ Categories:
29
+ - procedure: How to do something (steps, commands, workflows)
30
+ - directive: Always/never rules, standing instructions
31
+ - decision: Architectural or design choices with reasoning
32
+ - fact: Project-specific information worth remembering
33
+
34
+ Output ONLY valid JSON lines, one per extracted item. If nothing worth extracting, output nothing.
35
+
36
+ CONVERSATION:
37
+ """
38
+
39
+
40
+ def _find_opencode() -> str | None:
41
+ """Find opencode binary."""
42
+ opencode_path = shutil.which("opencode")
43
+ if opencode_path:
44
+ return opencode_path
45
+
46
+ for path in [
47
+ Path.home() / ".opencode/bin/opencode",
48
+ Path("/usr/local/bin/opencode"),
49
+ ]:
50
+ if path.exists():
51
+ return str(path)
52
+
53
+ return None
54
+
55
+
56
+ async def call_opencode(prompt: str, working_directory: str | None = None) -> str:
57
+ """Call opencode CLI to process prompt."""
58
+ opencode_path = _find_opencode()
59
+ if not opencode_path:
60
+ raise FileNotFoundError("opencode not found in PATH or ~/.opencode/bin/")
61
+
62
+ cwd = working_directory or str(Path.home() / "gitlab_projects")
63
+
64
+ proc = await asyncio.create_subprocess_exec(
65
+ opencode_path,
66
+ "run",
67
+ "--dangerously-skip-permissions",
68
+ prompt,
69
+ cwd=cwd,
70
+ stdin=asyncio.subprocess.DEVNULL,
71
+ stdout=asyncio.subprocess.PIPE,
72
+ stderr=asyncio.subprocess.PIPE,
73
+ )
74
+ stdout, stderr = await asyncio.wait_for(
75
+ proc.communicate(),
76
+ timeout=120.0,
77
+ )
78
+ return stdout.decode() if stdout else ""
79
+
80
+
81
+ async def extract_knowledge_from_conversation(
82
+ conv_id: int,
83
+ content: str,
84
+ project: str | None,
85
+ source_file: str | None,
86
+ sqlite: SQLiteStorage,
87
+ embeddings: EmbeddingEngine,
88
+ vectors: VectorStorage,
89
+ working_directory: str | None = None,
90
+ ) -> int:
91
+ """Extract knowledge from a single conversation and store it.
92
+
93
+ Returns number of items extracted and stored.
94
+ """
95
+ # Truncate very long conversations
96
+ if len(content) > 15000:
97
+ content = content[:15000] + "\n\n[... truncated ...]"
98
+
99
+ full_prompt = EXTRACTION_PROMPT + content
100
+
101
+ try:
102
+ response = await call_opencode(full_prompt, working_directory=working_directory)
103
+ except asyncio.TimeoutError:
104
+ logger.warning(f"Timeout extracting from conversation {conv_id}")
105
+ return 0
106
+ except FileNotFoundError as e:
107
+ logger.error(f"opencode not found: {e}")
108
+ return 0
109
+ except Exception as e:
110
+ logger.error(f"Error calling opencode for conversation {conv_id}: {e}")
111
+ return 0
112
+
113
+ # Parse JSON lines from response
114
+ extracted = []
115
+ for line in response.split("\n"):
116
+ line = line.strip()
117
+ if not line or not line.startswith("{"):
118
+ continue
119
+ try:
120
+ item = json.loads(line)
121
+ if "category" in item and "content" in item:
122
+ extracted.append(item)
123
+ except json.JSONDecodeError:
124
+ continue
125
+
126
+ if not extracted:
127
+ return 0
128
+
129
+ count = 0
130
+ category_map = {
131
+ "procedure": MemoryCategory.PROCEDURE,
132
+ "directive": MemoryCategory.DIRECTIVE,
133
+ "decision": MemoryCategory.DECISION,
134
+ "fact": MemoryCategory.FACT,
135
+ }
136
+
137
+ for item in extracted:
138
+ category_str = item.get("category", "fact")
139
+ category = category_map.get(category_str, MemoryCategory.FACT)
140
+
141
+ memory = Memory(
142
+ category=category,
143
+ content=item.get("content", ""),
144
+ what=item.get("what"),
145
+ why=item.get("why"),
146
+ learned=item.get("learned"),
147
+ project=project,
148
+ source_file=source_file,
149
+ )
150
+
151
+ memory_id = sqlite.insert_memory(memory)
152
+
153
+ # Embed and store vector
154
+ embedding = embeddings.embed(memory.embedding_content())
155
+ vectors.add(f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding)
156
+
157
+ # Link back to source conversation
158
+ link = MemoryLink(
159
+ source_memory_id=conv_id,
160
+ target_memory_id=memory_id,
161
+ link_type=LinkType.EXTENDS,
162
+ strength=0.9,
163
+ reason="Knowledge extracted from conversation via LLM",
164
+ )
165
+ sqlite.insert_link(link)
166
+
167
+ count += 1
168
+ logger.info(f"Extracted [{category_str}]: {item.get('what', 'No summary')[:50]}")
169
+
170
+ return count
171
+
172
+
173
+ def get_unprocessed_conversations(
174
+ sqlite: SQLiteStorage,
175
+ since_days: int | None = None,
176
+ limit: int = 100,
177
+ ) -> list[dict]:
178
+ """Get conversations that haven't been processed for knowledge extraction.
179
+
180
+ Args:
181
+ since_days: Only look at conversations from last N days. None = all time.
182
+ limit: Maximum conversations to return.
183
+ """
184
+ with sqlite._get_conn() as conn:
185
+ if since_days is not None:
186
+ cutoff = datetime.now(UTC) - timedelta(days=since_days)
187
+ cursor = conn.execute(
188
+ """
189
+ SELECT m.id, m.content, m.what, m.project, m.source_file, m.created_at
190
+ FROM memories m
191
+ WHERE m.category = 'conversation'
192
+ AND m.resolved_at IS NULL
193
+ AND m.created_at > ?
194
+ AND NOT EXISTS (
195
+ SELECT 1 FROM memory_links ml
196
+ JOIN memories m2 ON ml.target_memory_id = m2.id
197
+ WHERE ml.source_memory_id = m.id
198
+ AND m2.category IN ('procedure', 'directive', 'decision')
199
+ AND ml.reason LIKE '%extracted from conversation%'
200
+ )
201
+ ORDER BY m.created_at DESC
202
+ LIMIT ?
203
+ """,
204
+ (cutoff.isoformat(), limit),
205
+ )
206
+ else:
207
+ cursor = conn.execute(
208
+ """
209
+ SELECT m.id, m.content, m.what, m.project, m.source_file, m.created_at
210
+ FROM memories m
211
+ WHERE m.category = 'conversation'
212
+ AND m.resolved_at IS NULL
213
+ AND NOT EXISTS (
214
+ SELECT 1 FROM memory_links ml
215
+ JOIN memories m2 ON ml.target_memory_id = m2.id
216
+ WHERE ml.source_memory_id = m.id
217
+ AND m2.category IN ('procedure', 'directive', 'decision')
218
+ AND ml.reason LIKE '%extracted from conversation%'
219
+ )
220
+ ORDER BY m.created_at DESC
221
+ LIMIT ?
222
+ """,
223
+ (limit,),
224
+ )
225
+ return [dict(row) for row in cursor.fetchall()]
@@ -0,0 +1,142 @@
1
+ """Historical ingest of OpenCode database sessions."""
2
+
3
+ import logging
4
+ import sqlite3
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from opencode_memory.config import Config
9
+ from opencode_memory.ingestion.embeddings import EmbeddingEngine
10
+ from opencode_memory.ingestion.opencode_db import OpenCodeDBObserver
11
+ from opencode_memory.storage.sqlite import SQLiteStorage
12
+ from opencode_memory.storage.vectors import VectorStorage
13
+
14
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def run_historical_ingest(
19
+ opencode_db_path: Path | None = None,
20
+ batch_size: int = 100,
21
+ max_sessions: int | None = None,
22
+ ) -> None:
23
+ """Run historical ingest of OpenCode sessions.
24
+
25
+ Args:
26
+ opencode_db_path: Path to OpenCode database. Defaults to ~/.local/share/opencode/opencode.db
27
+ batch_size: Number of sessions to process before reporting progress
28
+ max_sessions: Maximum sessions to process (None for all)
29
+ """
30
+ if opencode_db_path is None:
31
+ opencode_db_path = Path.home() / ".local/share/opencode/opencode.db"
32
+
33
+ if not opencode_db_path.exists():
34
+ logger.error(f"OpenCode database not found: {opencode_db_path}")
35
+ return
36
+
37
+ config = Config.load()
38
+ sqlite = SQLiteStorage(config.db_path)
39
+ embeddings = EmbeddingEngine()
40
+ vectors = VectorStorage(config.vectors_path, embeddings.dimension)
41
+ observer = OpenCodeDBObserver(opencode_db_path)
42
+
43
+ ingest_state = sqlite.get_ingest_state("opencode_db")
44
+ last_session_id = ingest_state.get("last_id") if ingest_state else None
45
+
46
+ logger.info("Starting historical ingest of OpenCode database")
47
+ logger.info(f"Source: {opencode_db_path}")
48
+ if last_session_id:
49
+ logger.info(f"Resuming from session: {last_session_id}")
50
+
51
+ with sqlite3.connect(f"file:{opencode_db_path}?mode=ro", uri=True) as conn:
52
+ conn.row_factory = sqlite3.Row
53
+
54
+ if last_session_id:
55
+ cursor = conn.execute("SELECT COUNT(*) FROM session WHERE id > ?", (last_session_id,))
56
+ else:
57
+ cursor = conn.execute("SELECT COUNT(*) FROM session")
58
+
59
+ total_sessions = cursor.fetchone()[0]
60
+ if max_sessions:
61
+ total_sessions = min(total_sessions, max_sessions)
62
+
63
+ logger.info(f"Sessions to process: {total_sessions}")
64
+
65
+ if last_session_id:
66
+ cursor = conn.execute(
67
+ "SELECT * FROM session WHERE id > ? ORDER BY time_created ASC", (last_session_id,)
68
+ )
69
+ else:
70
+ cursor = conn.execute("SELECT * FROM session ORDER BY time_created ASC")
71
+
72
+ processed = 0
73
+ memories_created = 0
74
+
75
+ for row in cursor:
76
+ if max_sessions and processed >= max_sessions:
77
+ break
78
+
79
+ session = dict(row)
80
+ session_id = session["id"]
81
+
82
+ try:
83
+ memory = observer.extract_session_summary(session)
84
+ if memory:
85
+ memory_id = sqlite.insert_memory(memory)
86
+ embedding = embeddings.embed(memory.embedding_content())
87
+ vectors.add(
88
+ f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding
89
+ )
90
+ memories_created += 1
91
+ except Exception as e:
92
+ logger.warning(f"Error processing session {session_id}: {e}")
93
+
94
+ processed += 1
95
+
96
+ if processed % batch_size == 0:
97
+ sqlite.set_ingest_state("opencode_db", datetime.now(UTC).isoformat(), session_id)
98
+ logger.info(
99
+ f"Progress: {processed}/{total_sessions} sessions, {memories_created} memories"
100
+ )
101
+
102
+ sqlite.set_ingest_state(
103
+ "opencode_db",
104
+ datetime.now(UTC).isoformat(),
105
+ session_id if processed > 0 else last_session_id,
106
+ )
107
+
108
+ logger.info(
109
+ f"Complete: {processed} sessions processed, {memories_created} memories created"
110
+ )
111
+
112
+
113
+ def main() -> None:
114
+ """CLI entry point for historical ingest."""
115
+ import argparse
116
+
117
+ parser = argparse.ArgumentParser(description="Historical ingest of OpenCode sessions")
118
+ parser.add_argument(
119
+ "--db-path",
120
+ type=Path,
121
+ default=None,
122
+ help="Path to OpenCode database",
123
+ )
124
+ parser.add_argument(
125
+ "--batch-size",
126
+ type=int,
127
+ default=100,
128
+ help="Sessions per progress report",
129
+ )
130
+ parser.add_argument(
131
+ "--max-sessions",
132
+ type=int,
133
+ default=None,
134
+ help="Maximum sessions to process",
135
+ )
136
+
137
+ args = parser.parse_args()
138
+ run_historical_ingest(args.db_path, args.batch_size, args.max_sessions)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()