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
|
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()
|