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,2795 @@
|
|
|
1
|
+
"""MCP server for opencode-memory."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server import Server
|
|
14
|
+
from mcp.server.stdio import stdio_server
|
|
15
|
+
from mcp.types import TextContent, Tool
|
|
16
|
+
|
|
17
|
+
from opencode_memory.cache import MemoryCache
|
|
18
|
+
from opencode_memory.config import Config
|
|
19
|
+
from opencode_memory.daemon import IngestionDaemon
|
|
20
|
+
from opencode_memory.enrichment.gitlab import GitLabEnricher
|
|
21
|
+
from opencode_memory.ingestion.embeddings import EmbeddingEngine
|
|
22
|
+
from opencode_memory.ingestion.parser import MarkdownParser
|
|
23
|
+
from opencode_memory.models import (
|
|
24
|
+
BootContext,
|
|
25
|
+
Entity,
|
|
26
|
+
EntityType,
|
|
27
|
+
LinkType,
|
|
28
|
+
Memory,
|
|
29
|
+
MemoryCategory,
|
|
30
|
+
MemoryLink,
|
|
31
|
+
)
|
|
32
|
+
from opencode_memory.query.hybrid import HybridSearchEngine
|
|
33
|
+
from opencode_memory.session.registry import SessionRegistry
|
|
34
|
+
from opencode_memory.storage.sqlite import SQLiteStorage
|
|
35
|
+
from opencode_memory.storage.vectors import VectorStorage
|
|
36
|
+
|
|
37
|
+
logging.basicConfig(level=logging.INFO)
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _format_age(created_at: datetime) -> str:
|
|
42
|
+
"""Format memory age as human-readable string."""
|
|
43
|
+
from datetime import timezone as tz
|
|
44
|
+
|
|
45
|
+
now = datetime.now(tz.utc)
|
|
46
|
+
# Handle naive datetimes by assuming UTC
|
|
47
|
+
if created_at.tzinfo is None:
|
|
48
|
+
created_at = created_at.replace(tzinfo=tz.utc)
|
|
49
|
+
|
|
50
|
+
delta = now - created_at
|
|
51
|
+
days = delta.days
|
|
52
|
+
|
|
53
|
+
if days == 0:
|
|
54
|
+
hours = delta.seconds // 3600
|
|
55
|
+
if hours == 0:
|
|
56
|
+
minutes = delta.seconds // 60
|
|
57
|
+
return f"{minutes}m" if minutes > 0 else "now"
|
|
58
|
+
return f"{hours}h"
|
|
59
|
+
elif days == 1:
|
|
60
|
+
return "1d"
|
|
61
|
+
elif days < 7:
|
|
62
|
+
return f"{days}d"
|
|
63
|
+
elif days < 30:
|
|
64
|
+
weeks = days // 7
|
|
65
|
+
return f"{weeks}w"
|
|
66
|
+
elif days < 365:
|
|
67
|
+
months = days // 30
|
|
68
|
+
return f"{months}mo"
|
|
69
|
+
else:
|
|
70
|
+
years = days // 365
|
|
71
|
+
return f"{years}y"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _detect_current_project() -> str | None:
|
|
75
|
+
"""Detect current project from git remote URL."""
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["git", "remote", "get-url", "origin"],
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=5,
|
|
82
|
+
cwd=os.getcwd(),
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
url = result.stdout.strip()
|
|
88
|
+
# Parse git@gitlab.com:group/project.git or https://gitlab.com/group/project.git
|
|
89
|
+
match = re.search(r"(?:git@[^:]+:|https?://[^/]+/)(.+?)(?:\.git)?$", url)
|
|
90
|
+
if match:
|
|
91
|
+
return match.group(1)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
MAX_PENDING_EMBEDDINGS = 100 # Max queued embeddings before blocking
|
|
98
|
+
CACHE_CLEANUP_INTERVAL_SECONDS = 60 # Clean expired cache entries every minute
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BackgroundTaskRegistry:
|
|
102
|
+
"""Track running background tasks for status reporting."""
|
|
103
|
+
|
|
104
|
+
def __init__(self):
|
|
105
|
+
self._tasks: dict[str, dict] = {} # task_id -> {name, started_at, task}
|
|
106
|
+
|
|
107
|
+
def register(self, name: str, task: asyncio.Task) -> str:
|
|
108
|
+
"""Register a background task. Returns task_id."""
|
|
109
|
+
import uuid
|
|
110
|
+
|
|
111
|
+
task_id = str(uuid.uuid4())[:8]
|
|
112
|
+
self._tasks[task_id] = {
|
|
113
|
+
"name": name,
|
|
114
|
+
"started_at": time.time(),
|
|
115
|
+
"task": task,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Auto-cleanup when task completes
|
|
119
|
+
def cleanup(t):
|
|
120
|
+
self._tasks.pop(task_id, None)
|
|
121
|
+
|
|
122
|
+
task.add_done_callback(cleanup)
|
|
123
|
+
return task_id
|
|
124
|
+
|
|
125
|
+
def get_running_tasks(self) -> list[dict]:
|
|
126
|
+
"""Get list of currently running tasks."""
|
|
127
|
+
now = time.time()
|
|
128
|
+
result = []
|
|
129
|
+
for task_id, info in list(self._tasks.items()):
|
|
130
|
+
if not info["task"].done():
|
|
131
|
+
result.append(
|
|
132
|
+
{
|
|
133
|
+
"id": task_id,
|
|
134
|
+
"name": info["name"],
|
|
135
|
+
"running_seconds": int(now - info["started_at"]),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def is_task_running(self, name: str) -> bool:
|
|
141
|
+
"""Check if a task with given name is running."""
|
|
142
|
+
return any(
|
|
143
|
+
info["name"] == name and not info["task"].done() for info in self._tasks.values()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_background_tasks = BackgroundTaskRegistry()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MemoryServer:
|
|
151
|
+
"""MCP server providing memory tools."""
|
|
152
|
+
|
|
153
|
+
# Limit concurrent background embedding tasks to prevent resource exhaustion
|
|
154
|
+
_embedding_semaphore: asyncio.Semaphore | None = None
|
|
155
|
+
_pending_embeddings: set[asyncio.Task] = set()
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
async def wait_for_pending_embeddings(cls, timeout: float = 5.0) -> int:
|
|
159
|
+
"""Wait for pending background embedding tasks to complete.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
timeout: Maximum seconds to wait (default 5)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Number of tasks that were still pending (0 = all completed)
|
|
166
|
+
"""
|
|
167
|
+
if not cls._pending_embeddings:
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
pending = list(cls._pending_embeddings)
|
|
171
|
+
if not pending:
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
done, still_pending = await asyncio.wait(pending, timeout=timeout)
|
|
175
|
+
return len(still_pending)
|
|
176
|
+
|
|
177
|
+
def __init__(self, config: Config | None = None, enable_daemon: bool = True):
|
|
178
|
+
self.config = config or Config.load()
|
|
179
|
+
self.config.storage_path.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
self.sqlite = SQLiteStorage(self.config.db_path)
|
|
182
|
+
self.embeddings = EmbeddingEngine()
|
|
183
|
+
self.vectors = VectorStorage(self.config.vectors_path, self.embeddings.dimension)
|
|
184
|
+
self.search_engine = HybridSearchEngine(self.sqlite, self.vectors, self.embeddings)
|
|
185
|
+
self.session_registry = SessionRegistry(self.sqlite)
|
|
186
|
+
self.parser = MarkdownParser()
|
|
187
|
+
self.memory_cache = MemoryCache(self.sqlite) # Uses defaults: 50k entries, 24h TTL
|
|
188
|
+
self._cache_cleanup_task: asyncio.Task | None = None
|
|
189
|
+
|
|
190
|
+
# Initialize semaphore (max 4 concurrent embeddings)
|
|
191
|
+
if MemoryServer._embedding_semaphore is None:
|
|
192
|
+
MemoryServer._embedding_semaphore = asyncio.Semaphore(4)
|
|
193
|
+
|
|
194
|
+
self._enable_daemon = enable_daemon
|
|
195
|
+
self.daemon: IngestionDaemon | None = None
|
|
196
|
+
if enable_daemon:
|
|
197
|
+
self.daemon = IngestionDaemon(self.config, self.sqlite, self.vectors, self.embeddings)
|
|
198
|
+
|
|
199
|
+
self.enricher = GitLabEnricher()
|
|
200
|
+
|
|
201
|
+
self.server = Server("opencode-memory")
|
|
202
|
+
self._register_tools()
|
|
203
|
+
|
|
204
|
+
def _register_tools(self) -> None:
|
|
205
|
+
"""Register MCP tools."""
|
|
206
|
+
|
|
207
|
+
@self.server.list_tools()
|
|
208
|
+
async def list_tools() -> list[Tool]:
|
|
209
|
+
return [
|
|
210
|
+
Tool(
|
|
211
|
+
name="recall",
|
|
212
|
+
description=(
|
|
213
|
+
"Search long-term memory semantically. Use this to find relevant context "
|
|
214
|
+
"before working on any task. Examples: 'database migration guidelines', "
|
|
215
|
+
"'how to write GitLab comments', 'MR review process', 'known blockers'. "
|
|
216
|
+
"Combines semantic search with full-text search for best results."
|
|
217
|
+
),
|
|
218
|
+
inputSchema={
|
|
219
|
+
"type": "object",
|
|
220
|
+
"properties": {
|
|
221
|
+
"query": {
|
|
222
|
+
"type": "string",
|
|
223
|
+
"description": "Natural language search query",
|
|
224
|
+
},
|
|
225
|
+
"limit": {
|
|
226
|
+
"type": "integer",
|
|
227
|
+
"description": "Max results (default 10)",
|
|
228
|
+
"default": 10,
|
|
229
|
+
},
|
|
230
|
+
"project": {
|
|
231
|
+
"type": "string",
|
|
232
|
+
"description": "Filter by project (e.g. 'gitlab-org/gitlab'). Use 'auto' to detect from git remote. If not provided, searches all projects.",
|
|
233
|
+
},
|
|
234
|
+
"compact": {
|
|
235
|
+
"type": "boolean",
|
|
236
|
+
"description": "Return compact results (truncated content, no source) to reduce token usage. Default false.",
|
|
237
|
+
"default": False,
|
|
238
|
+
},
|
|
239
|
+
"since_days": {
|
|
240
|
+
"type": "integer",
|
|
241
|
+
"description": "Only search memories from the last N days",
|
|
242
|
+
},
|
|
243
|
+
"category": {
|
|
244
|
+
"type": "string",
|
|
245
|
+
"enum": [
|
|
246
|
+
"decision",
|
|
247
|
+
"blocker",
|
|
248
|
+
"procedure",
|
|
249
|
+
"fact",
|
|
250
|
+
"event",
|
|
251
|
+
"conversation",
|
|
252
|
+
"directive",
|
|
253
|
+
"plan",
|
|
254
|
+
"idea",
|
|
255
|
+
],
|
|
256
|
+
"description": "Filter by memory category",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
"required": ["query"],
|
|
260
|
+
},
|
|
261
|
+
),
|
|
262
|
+
Tool(
|
|
263
|
+
name="remember",
|
|
264
|
+
description=(
|
|
265
|
+
"Store an important memory for future sessions. Use this for: "
|
|
266
|
+
"decisions made (why we chose approach X), blockers discovered "
|
|
267
|
+
"(what's blocking MR !123), procedures learned (how to do X), "
|
|
268
|
+
"or facts worth preserving. Memories persist across all sessions."
|
|
269
|
+
),
|
|
270
|
+
inputSchema={
|
|
271
|
+
"type": "object",
|
|
272
|
+
"properties": {
|
|
273
|
+
"content": {
|
|
274
|
+
"type": "string",
|
|
275
|
+
"description": "The memory content - be specific and include context",
|
|
276
|
+
},
|
|
277
|
+
"category": {
|
|
278
|
+
"type": "string",
|
|
279
|
+
"enum": [
|
|
280
|
+
"decision",
|
|
281
|
+
"blocker",
|
|
282
|
+
"procedure",
|
|
283
|
+
"fact",
|
|
284
|
+
"event",
|
|
285
|
+
"directive",
|
|
286
|
+
"plan",
|
|
287
|
+
"idea",
|
|
288
|
+
],
|
|
289
|
+
"description": (
|
|
290
|
+
"decision: architectural/design choices; "
|
|
291
|
+
"blocker: obstacles preventing progress; "
|
|
292
|
+
"procedure: how-to knowledge; "
|
|
293
|
+
"fact: project-specific information; "
|
|
294
|
+
"event: significant occurrences; "
|
|
295
|
+
"directive: always-on instructions for every session; "
|
|
296
|
+
"plan: long-term goals and strategies to achieve them; "
|
|
297
|
+
"idea: future possibilities, deferred considerations, things to try later"
|
|
298
|
+
),
|
|
299
|
+
},
|
|
300
|
+
"entities": {
|
|
301
|
+
"type": "array",
|
|
302
|
+
"items": {"type": "string"},
|
|
303
|
+
"description": "Related entities: !123 (MR), #456 (issue), &789 (epic), @user",
|
|
304
|
+
},
|
|
305
|
+
"what": {
|
|
306
|
+
"type": "string",
|
|
307
|
+
"description": "Brief summary of what happened",
|
|
308
|
+
},
|
|
309
|
+
"why": {
|
|
310
|
+
"type": "string",
|
|
311
|
+
"description": "Why this matters or why the decision was made",
|
|
312
|
+
},
|
|
313
|
+
"learned": {
|
|
314
|
+
"type": "string",
|
|
315
|
+
"description": "Key takeaway or lesson for future reference",
|
|
316
|
+
},
|
|
317
|
+
"project": {
|
|
318
|
+
"type": "string",
|
|
319
|
+
"description": "Project context (e.g. 'gitlab-org/gitlab'). Auto-detected if not provided.",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
"required": ["content", "category"],
|
|
323
|
+
},
|
|
324
|
+
),
|
|
325
|
+
Tool(
|
|
326
|
+
name="get_context",
|
|
327
|
+
description=(
|
|
328
|
+
"Get all stored memories related to an entity. ALWAYS call this before "
|
|
329
|
+
"working on an MR, issue, or epic to understand history, prior decisions, "
|
|
330
|
+
"and known blockers. Returns memories tagged with the entity."
|
|
331
|
+
),
|
|
332
|
+
inputSchema={
|
|
333
|
+
"type": "object",
|
|
334
|
+
"properties": {
|
|
335
|
+
"entity_ref": {
|
|
336
|
+
"type": "string",
|
|
337
|
+
"description": "Entity reference: !123 (MR), #456 (issue), &789 (epic), @user",
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
"required": ["entity_ref"],
|
|
341
|
+
},
|
|
342
|
+
),
|
|
343
|
+
Tool(
|
|
344
|
+
name="get_active_sessions",
|
|
345
|
+
description=(
|
|
346
|
+
"List other active OpenCode sessions and what they're working on. "
|
|
347
|
+
"Check this before claiming items to avoid conflicts with parallel sessions."
|
|
348
|
+
),
|
|
349
|
+
inputSchema={
|
|
350
|
+
"type": "object",
|
|
351
|
+
"properties": {},
|
|
352
|
+
},
|
|
353
|
+
),
|
|
354
|
+
Tool(
|
|
355
|
+
name="session_start",
|
|
356
|
+
description=(
|
|
357
|
+
"Register this session at startup. Enables session coordination, "
|
|
358
|
+
"item claiming, and activity tracking across parallel OpenCode instances."
|
|
359
|
+
),
|
|
360
|
+
inputSchema={
|
|
361
|
+
"type": "object",
|
|
362
|
+
"properties": {
|
|
363
|
+
"session_id": {
|
|
364
|
+
"type": "string",
|
|
365
|
+
"description": "Unique session identifier (e.g., 'mr-review-225172')",
|
|
366
|
+
},
|
|
367
|
+
"working_on": {
|
|
368
|
+
"type": "string",
|
|
369
|
+
"description": "Brief description of the task",
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
"required": ["session_id"],
|
|
373
|
+
},
|
|
374
|
+
),
|
|
375
|
+
Tool(
|
|
376
|
+
name="session_heartbeat",
|
|
377
|
+
description="Update session heartbeat. Call periodically during long tasks.",
|
|
378
|
+
inputSchema={
|
|
379
|
+
"type": "object",
|
|
380
|
+
"properties": {
|
|
381
|
+
"session_id": {
|
|
382
|
+
"type": "string",
|
|
383
|
+
"description": "Session identifier",
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
"required": ["session_id"],
|
|
387
|
+
},
|
|
388
|
+
),
|
|
389
|
+
Tool(
|
|
390
|
+
name="session_end",
|
|
391
|
+
description=(
|
|
392
|
+
"End a session. Provide a summary to persist key accomplishments "
|
|
393
|
+
"and decisions for future sessions to recall."
|
|
394
|
+
),
|
|
395
|
+
inputSchema={
|
|
396
|
+
"type": "object",
|
|
397
|
+
"properties": {
|
|
398
|
+
"session_id": {
|
|
399
|
+
"type": "string",
|
|
400
|
+
"description": "Session identifier",
|
|
401
|
+
},
|
|
402
|
+
"summary": {
|
|
403
|
+
"type": "string",
|
|
404
|
+
"description": "Summary of accomplishments and key decisions",
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
"required": ["session_id"],
|
|
408
|
+
},
|
|
409
|
+
),
|
|
410
|
+
Tool(
|
|
411
|
+
name="claim_item",
|
|
412
|
+
description=(
|
|
413
|
+
"Claim exclusive ownership of an item (MR, issue, epic) to prevent "
|
|
414
|
+
"other sessions from working on it simultaneously. ALWAYS claim before "
|
|
415
|
+
"making changes. Returns current owner if already claimed."
|
|
416
|
+
),
|
|
417
|
+
inputSchema={
|
|
418
|
+
"type": "object",
|
|
419
|
+
"properties": {
|
|
420
|
+
"session_id": {
|
|
421
|
+
"type": "string",
|
|
422
|
+
"description": "Your session identifier",
|
|
423
|
+
},
|
|
424
|
+
"item_ref": {
|
|
425
|
+
"type": "string",
|
|
426
|
+
"description": "Item to claim: !123 (MR), #456 (issue), &789 (epic)",
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
"required": ["session_id", "item_ref"],
|
|
430
|
+
},
|
|
431
|
+
),
|
|
432
|
+
Tool(
|
|
433
|
+
name="release_item",
|
|
434
|
+
description=(
|
|
435
|
+
"Release a claimed item so other sessions can work on it. "
|
|
436
|
+
"Call this when done working on an item."
|
|
437
|
+
),
|
|
438
|
+
inputSchema={
|
|
439
|
+
"type": "object",
|
|
440
|
+
"properties": {
|
|
441
|
+
"session_id": {
|
|
442
|
+
"type": "string",
|
|
443
|
+
"description": "Your session identifier",
|
|
444
|
+
},
|
|
445
|
+
"item_ref": {
|
|
446
|
+
"type": "string",
|
|
447
|
+
"description": "Item to release",
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
"required": ["session_id", "item_ref"],
|
|
451
|
+
},
|
|
452
|
+
),
|
|
453
|
+
Tool(
|
|
454
|
+
name="get_boot_context",
|
|
455
|
+
description=(
|
|
456
|
+
"Get startup context for a new session. Returns: user identity, "
|
|
457
|
+
"active parallel sessions, unresolved blockers, and recent decisions. "
|
|
458
|
+
"Call this at the start of every session for situational awareness."
|
|
459
|
+
),
|
|
460
|
+
inputSchema={
|
|
461
|
+
"type": "object",
|
|
462
|
+
"properties": {},
|
|
463
|
+
},
|
|
464
|
+
),
|
|
465
|
+
Tool(
|
|
466
|
+
name="search_history",
|
|
467
|
+
description=(
|
|
468
|
+
"Search past decisions, blockers, and events with optional category filter. "
|
|
469
|
+
"Use for targeted searches like 'all blockers related to migrations' or "
|
|
470
|
+
"'decisions about API design'."
|
|
471
|
+
),
|
|
472
|
+
inputSchema={
|
|
473
|
+
"type": "object",
|
|
474
|
+
"properties": {
|
|
475
|
+
"query": {
|
|
476
|
+
"type": "string",
|
|
477
|
+
"description": "Search query",
|
|
478
|
+
},
|
|
479
|
+
"category": {
|
|
480
|
+
"type": "string",
|
|
481
|
+
"enum": [
|
|
482
|
+
"decision",
|
|
483
|
+
"blocker",
|
|
484
|
+
"procedure",
|
|
485
|
+
"fact",
|
|
486
|
+
"event",
|
|
487
|
+
"conversation",
|
|
488
|
+
"directive",
|
|
489
|
+
"plan",
|
|
490
|
+
"idea",
|
|
491
|
+
],
|
|
492
|
+
"description": "Filter by memory category",
|
|
493
|
+
},
|
|
494
|
+
"limit": {
|
|
495
|
+
"type": "integer",
|
|
496
|
+
"default": 20,
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
"required": ["query"],
|
|
500
|
+
},
|
|
501
|
+
),
|
|
502
|
+
Tool(
|
|
503
|
+
name="ingest_file",
|
|
504
|
+
description=(
|
|
505
|
+
"Manually ingest a markdown file into memory. Use for importing "
|
|
506
|
+
"guidelines, notes, or documentation that should be searchable."
|
|
507
|
+
),
|
|
508
|
+
inputSchema={
|
|
509
|
+
"type": "object",
|
|
510
|
+
"properties": {
|
|
511
|
+
"file_path": {
|
|
512
|
+
"type": "string",
|
|
513
|
+
"description": "Absolute path to the file to ingest",
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
"required": ["file_path"],
|
|
517
|
+
},
|
|
518
|
+
),
|
|
519
|
+
Tool(
|
|
520
|
+
name="enrich_entity",
|
|
521
|
+
description=(
|
|
522
|
+
"Fetch metadata for an entity (MR, issue, epic, user) from GitLab API. "
|
|
523
|
+
"Updates the entity with title, state, labels, and other metadata. "
|
|
524
|
+
"Useful when you want current information about an entity."
|
|
525
|
+
),
|
|
526
|
+
inputSchema={
|
|
527
|
+
"type": "object",
|
|
528
|
+
"properties": {
|
|
529
|
+
"entity_ref": {
|
|
530
|
+
"type": "string",
|
|
531
|
+
"description": "Entity reference: !123 (MR), #456 (issue), &789 (epic), @user",
|
|
532
|
+
},
|
|
533
|
+
"project": {
|
|
534
|
+
"type": "string",
|
|
535
|
+
"description": "Project path (default: gitlab-org/gitlab)",
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
"required": ["entity_ref"],
|
|
539
|
+
},
|
|
540
|
+
),
|
|
541
|
+
Tool(
|
|
542
|
+
name="resolve_blocker",
|
|
543
|
+
description=(
|
|
544
|
+
"Mark a blocker as resolved. Use when a blocking issue has been fixed "
|
|
545
|
+
"or is no longer relevant. Resolved blockers won't appear in boot context."
|
|
546
|
+
),
|
|
547
|
+
inputSchema={
|
|
548
|
+
"type": "object",
|
|
549
|
+
"properties": {
|
|
550
|
+
"memory_id": {
|
|
551
|
+
"type": "integer",
|
|
552
|
+
"description": "ID of the blocker memory to resolve",
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
"required": ["memory_id"],
|
|
556
|
+
},
|
|
557
|
+
),
|
|
558
|
+
Tool(
|
|
559
|
+
name="unresolve_blocker",
|
|
560
|
+
description=(
|
|
561
|
+
"Mark a previously resolved blocker as unresolved again. "
|
|
562
|
+
"Use if a blocker resurfaces or was resolved prematurely."
|
|
563
|
+
),
|
|
564
|
+
inputSchema={
|
|
565
|
+
"type": "object",
|
|
566
|
+
"properties": {
|
|
567
|
+
"memory_id": {
|
|
568
|
+
"type": "integer",
|
|
569
|
+
"description": "ID of the blocker memory to unresolve",
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
"required": ["memory_id"],
|
|
573
|
+
},
|
|
574
|
+
),
|
|
575
|
+
Tool(
|
|
576
|
+
name="consolidate_memory",
|
|
577
|
+
description=(
|
|
578
|
+
"Analyze all memories and identify issues: stale records (old facts that may be outdated), "
|
|
579
|
+
"duplicates (similar content), and potential contradictions. Returns a report with "
|
|
580
|
+
"recommendations for cleanup. Use periodically to maintain memory quality."
|
|
581
|
+
),
|
|
582
|
+
inputSchema={
|
|
583
|
+
"type": "object",
|
|
584
|
+
"properties": {
|
|
585
|
+
"project": {
|
|
586
|
+
"type": "string",
|
|
587
|
+
"description": "Filter by project (optional). Use 'auto' to detect from git.",
|
|
588
|
+
},
|
|
589
|
+
"days_stale": {
|
|
590
|
+
"type": "integer",
|
|
591
|
+
"description": "Consider records older than this many days as potentially stale (default: 30)",
|
|
592
|
+
"default": 30,
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
),
|
|
597
|
+
Tool(
|
|
598
|
+
name="log_session",
|
|
599
|
+
description=(
|
|
600
|
+
"Log a session summary with key learnings. Use at the end of significant work sessions "
|
|
601
|
+
"to preserve context for future sessions. Automatically extracts and stores insights."
|
|
602
|
+
),
|
|
603
|
+
inputSchema={
|
|
604
|
+
"type": "object",
|
|
605
|
+
"properties": {
|
|
606
|
+
"summary": {
|
|
607
|
+
"type": "string",
|
|
608
|
+
"description": "Brief summary of what was accomplished",
|
|
609
|
+
},
|
|
610
|
+
"learnings": {
|
|
611
|
+
"type": "array",
|
|
612
|
+
"items": {"type": "string"},
|
|
613
|
+
"description": "Key learnings or insights from the session",
|
|
614
|
+
},
|
|
615
|
+
"entities": {
|
|
616
|
+
"type": "array",
|
|
617
|
+
"items": {"type": "string"},
|
|
618
|
+
"description": "Related entities (!MR, #issue, &epic)",
|
|
619
|
+
},
|
|
620
|
+
"project": {
|
|
621
|
+
"type": "string",
|
|
622
|
+
"description": "Project context (auto-detected if not provided)",
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
"required": ["summary"],
|
|
626
|
+
},
|
|
627
|
+
),
|
|
628
|
+
Tool(
|
|
629
|
+
name="archive_memory",
|
|
630
|
+
description=(
|
|
631
|
+
"Archive an outdated memory record. Archived records are preserved for history "
|
|
632
|
+
"but won't appear in regular searches. Use for facts that are no longer true "
|
|
633
|
+
"or decisions that have been superseded."
|
|
634
|
+
),
|
|
635
|
+
inputSchema={
|
|
636
|
+
"type": "object",
|
|
637
|
+
"properties": {
|
|
638
|
+
"memory_id": {
|
|
639
|
+
"type": "integer",
|
|
640
|
+
"description": "ID of the memory to archive",
|
|
641
|
+
},
|
|
642
|
+
"reason": {
|
|
643
|
+
"type": "string",
|
|
644
|
+
"description": "Why this memory is being archived (e.g., 'superseded by new decision')",
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
"required": ["memory_id"],
|
|
648
|
+
},
|
|
649
|
+
),
|
|
650
|
+
Tool(
|
|
651
|
+
name="bootstrap_memory",
|
|
652
|
+
description=(
|
|
653
|
+
"Bootstrap project memory by scanning common project files (README, package.json, "
|
|
654
|
+
"Gemfile, etc.) and extracting basic facts. Run this once when starting work on "
|
|
655
|
+
"a new project to build initial knowledge base. Safe to run multiple times - "
|
|
656
|
+
"won't duplicate existing facts."
|
|
657
|
+
),
|
|
658
|
+
inputSchema={
|
|
659
|
+
"type": "object",
|
|
660
|
+
"properties": {
|
|
661
|
+
"path": {
|
|
662
|
+
"type": "string",
|
|
663
|
+
"description": "Project root path (defaults to current directory)",
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
),
|
|
668
|
+
Tool(
|
|
669
|
+
name="memory_status",
|
|
670
|
+
description=(
|
|
671
|
+
"Get memory system status including pending embedding queue, storage stats, "
|
|
672
|
+
"and daemon health. Use this to check if the system is keeping up with ingestion."
|
|
673
|
+
),
|
|
674
|
+
inputSchema={"type": "object", "properties": {}},
|
|
675
|
+
),
|
|
676
|
+
Tool(
|
|
677
|
+
name="get_linked_memories",
|
|
678
|
+
description=(
|
|
679
|
+
"Get memories linked to a specific memory. Use this to explore related context "
|
|
680
|
+
"when a search result has linked_memories hints. Returns full content of linked "
|
|
681
|
+
"memories with link type and reason."
|
|
682
|
+
),
|
|
683
|
+
inputSchema={
|
|
684
|
+
"type": "object",
|
|
685
|
+
"properties": {
|
|
686
|
+
"memory_id": {
|
|
687
|
+
"type": "integer",
|
|
688
|
+
"description": "ID of the memory to get links for",
|
|
689
|
+
},
|
|
690
|
+
"link_types": {
|
|
691
|
+
"type": "array",
|
|
692
|
+
"items": {
|
|
693
|
+
"type": "string",
|
|
694
|
+
"enum": [
|
|
695
|
+
"related",
|
|
696
|
+
"extends",
|
|
697
|
+
"supersedes",
|
|
698
|
+
"contradicts",
|
|
699
|
+
"same_entity",
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
"description": "Filter by link types (optional, returns all if not specified)",
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
"required": ["memory_id"],
|
|
706
|
+
},
|
|
707
|
+
),
|
|
708
|
+
Tool(
|
|
709
|
+
name="export_memories",
|
|
710
|
+
description=(
|
|
711
|
+
"Export memories to a JSON file for backup or transfer. "
|
|
712
|
+
"Can filter by project, category, or date range. "
|
|
713
|
+
"Returns the path to the exported file."
|
|
714
|
+
),
|
|
715
|
+
inputSchema={
|
|
716
|
+
"type": "object",
|
|
717
|
+
"properties": {
|
|
718
|
+
"output_path": {
|
|
719
|
+
"type": "string",
|
|
720
|
+
"description": "Path to write the export file (default: ~/opencode-memory-export.json)",
|
|
721
|
+
},
|
|
722
|
+
"project": {
|
|
723
|
+
"type": "string",
|
|
724
|
+
"description": "Filter by project (use 'auto' for current project)",
|
|
725
|
+
},
|
|
726
|
+
"categories": {
|
|
727
|
+
"type": "array",
|
|
728
|
+
"items": {"type": "string"},
|
|
729
|
+
"description": "Filter by categories (e.g., ['decision', 'procedure'])",
|
|
730
|
+
},
|
|
731
|
+
"since_days": {
|
|
732
|
+
"type": "integer",
|
|
733
|
+
"description": "Only export memories from the last N days",
|
|
734
|
+
},
|
|
735
|
+
"include_archived": {
|
|
736
|
+
"type": "boolean",
|
|
737
|
+
"description": "Include archived memories (default: false)",
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
),
|
|
742
|
+
Tool(
|
|
743
|
+
name="import_memories",
|
|
744
|
+
description=(
|
|
745
|
+
"Import memories from a JSON export file. "
|
|
746
|
+
"Use 'dry_run' to preview what will be imported without making changes. "
|
|
747
|
+
"Skips duplicates based on content similarity."
|
|
748
|
+
),
|
|
749
|
+
inputSchema={
|
|
750
|
+
"type": "object",
|
|
751
|
+
"properties": {
|
|
752
|
+
"input_path": {
|
|
753
|
+
"type": "string",
|
|
754
|
+
"description": "Path to the export file to import",
|
|
755
|
+
},
|
|
756
|
+
"dry_run": {
|
|
757
|
+
"type": "boolean",
|
|
758
|
+
"description": "Preview import without making changes (default: false)",
|
|
759
|
+
},
|
|
760
|
+
"skip_duplicates": {
|
|
761
|
+
"type": "boolean",
|
|
762
|
+
"description": "Skip memories that match existing content (default: true)",
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
"required": ["input_path"],
|
|
766
|
+
},
|
|
767
|
+
),
|
|
768
|
+
Tool(
|
|
769
|
+
name="bulk_archive",
|
|
770
|
+
description=(
|
|
771
|
+
"Archive multiple memories at once. Use with caution. "
|
|
772
|
+
"Can archive by IDs, category, or age. Returns count of archived memories."
|
|
773
|
+
),
|
|
774
|
+
inputSchema={
|
|
775
|
+
"type": "object",
|
|
776
|
+
"properties": {
|
|
777
|
+
"memory_ids": {
|
|
778
|
+
"type": "array",
|
|
779
|
+
"items": {"type": "integer"},
|
|
780
|
+
"description": "List of memory IDs to archive",
|
|
781
|
+
},
|
|
782
|
+
"category": {
|
|
783
|
+
"type": "string",
|
|
784
|
+
"description": "Archive all resolved memories in this category",
|
|
785
|
+
},
|
|
786
|
+
"older_than_days": {
|
|
787
|
+
"type": "integer",
|
|
788
|
+
"description": "Archive resolved memories older than N days",
|
|
789
|
+
},
|
|
790
|
+
"reason": {
|
|
791
|
+
"type": "string",
|
|
792
|
+
"description": "Reason for archiving (required)",
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
"required": ["reason"],
|
|
796
|
+
},
|
|
797
|
+
),
|
|
798
|
+
Tool(
|
|
799
|
+
name="delete_memory",
|
|
800
|
+
description=(
|
|
801
|
+
"Permanently delete a memory. Use archive_memory for soft delete. "
|
|
802
|
+
"This removes the memory from search, FTS index, and all links. Cannot be undone."
|
|
803
|
+
),
|
|
804
|
+
inputSchema={
|
|
805
|
+
"type": "object",
|
|
806
|
+
"properties": {
|
|
807
|
+
"memory_id": {
|
|
808
|
+
"type": "integer",
|
|
809
|
+
"description": "ID of the memory to delete",
|
|
810
|
+
},
|
|
811
|
+
"also_delete_vector": {
|
|
812
|
+
"type": "boolean",
|
|
813
|
+
"description": "Also delete from vector store (default true)",
|
|
814
|
+
"default": True,
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
"required": ["memory_id"],
|
|
818
|
+
},
|
|
819
|
+
),
|
|
820
|
+
Tool(
|
|
821
|
+
name="edit_memory",
|
|
822
|
+
description=(
|
|
823
|
+
"Edit a memory's content or metadata. Only provided fields are updated. "
|
|
824
|
+
"Use for correcting mistakes or updating stale information."
|
|
825
|
+
),
|
|
826
|
+
inputSchema={
|
|
827
|
+
"type": "object",
|
|
828
|
+
"properties": {
|
|
829
|
+
"memory_id": {
|
|
830
|
+
"type": "integer",
|
|
831
|
+
"description": "ID of the memory to edit",
|
|
832
|
+
},
|
|
833
|
+
"content": {
|
|
834
|
+
"type": "string",
|
|
835
|
+
"description": "New content (replaces existing)",
|
|
836
|
+
},
|
|
837
|
+
"what": {
|
|
838
|
+
"type": "string",
|
|
839
|
+
"description": "New 'what' summary",
|
|
840
|
+
},
|
|
841
|
+
"why": {
|
|
842
|
+
"type": "string",
|
|
843
|
+
"description": "New 'why' explanation",
|
|
844
|
+
},
|
|
845
|
+
"learned": {
|
|
846
|
+
"type": "string",
|
|
847
|
+
"description": "New 'learned' takeaway",
|
|
848
|
+
},
|
|
849
|
+
"recompute_embedding": {
|
|
850
|
+
"type": "boolean",
|
|
851
|
+
"description": "Recompute vector embedding if content changed (default true)",
|
|
852
|
+
"default": True,
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
"required": ["memory_id"],
|
|
856
|
+
},
|
|
857
|
+
),
|
|
858
|
+
]
|
|
859
|
+
|
|
860
|
+
@self.server.call_tool()
|
|
861
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
862
|
+
try:
|
|
863
|
+
result = await self._handle_tool(name, arguments)
|
|
864
|
+
return [TextContent(type="text", text=str(result))]
|
|
865
|
+
except Exception as e:
|
|
866
|
+
logger.exception(f"Error handling tool {name}")
|
|
867
|
+
return [TextContent(type="text", text=f"Error: {e}")]
|
|
868
|
+
|
|
869
|
+
async def _handle_tool(self, name: str, args: dict[str, Any]) -> Any:
|
|
870
|
+
"""Handle a tool call using dispatch table."""
|
|
871
|
+
# Dispatch table maps tool name to (handler, is_async, args_extractor)
|
|
872
|
+
# Using lambdas for args extraction keeps the mapping clean
|
|
873
|
+
dispatch = {
|
|
874
|
+
"recall": (
|
|
875
|
+
self._recall,
|
|
876
|
+
True,
|
|
877
|
+
lambda a: (
|
|
878
|
+
a["query"],
|
|
879
|
+
a.get("limit", 10),
|
|
880
|
+
a.get("project"),
|
|
881
|
+
a.get("compact", False),
|
|
882
|
+
a.get("since_days"),
|
|
883
|
+
a.get("category"),
|
|
884
|
+
),
|
|
885
|
+
),
|
|
886
|
+
"remember": (
|
|
887
|
+
self._remember,
|
|
888
|
+
True,
|
|
889
|
+
lambda a: (
|
|
890
|
+
a["content"],
|
|
891
|
+
a["category"],
|
|
892
|
+
a.get("entities", []),
|
|
893
|
+
a.get("what"),
|
|
894
|
+
a.get("why"),
|
|
895
|
+
a.get("learned"),
|
|
896
|
+
a.get("project"),
|
|
897
|
+
),
|
|
898
|
+
),
|
|
899
|
+
"get_context": (
|
|
900
|
+
self._get_context,
|
|
901
|
+
True, # Now async - includes semantic search
|
|
902
|
+
lambda a: (a["entity_ref"],),
|
|
903
|
+
),
|
|
904
|
+
"get_active_sessions": (
|
|
905
|
+
self._get_active_sessions,
|
|
906
|
+
False,
|
|
907
|
+
lambda a: (),
|
|
908
|
+
),
|
|
909
|
+
"session_start": (
|
|
910
|
+
self._session_start,
|
|
911
|
+
False,
|
|
912
|
+
lambda a: (a["session_id"], a.get("working_on")),
|
|
913
|
+
),
|
|
914
|
+
"session_heartbeat": (
|
|
915
|
+
self._session_heartbeat,
|
|
916
|
+
False,
|
|
917
|
+
lambda a: (a["session_id"],),
|
|
918
|
+
),
|
|
919
|
+
"session_end": (
|
|
920
|
+
self._session_end,
|
|
921
|
+
True,
|
|
922
|
+
lambda a: (a["session_id"], a.get("summary")),
|
|
923
|
+
),
|
|
924
|
+
"claim_item": (
|
|
925
|
+
self._claim_item,
|
|
926
|
+
False,
|
|
927
|
+
lambda a: (a["session_id"], a["item_ref"]),
|
|
928
|
+
),
|
|
929
|
+
"release_item": (
|
|
930
|
+
self._release_item,
|
|
931
|
+
False,
|
|
932
|
+
lambda a: (a["session_id"], a["item_ref"]),
|
|
933
|
+
),
|
|
934
|
+
"get_boot_context": (
|
|
935
|
+
self._get_boot_context,
|
|
936
|
+
False,
|
|
937
|
+
lambda a: (),
|
|
938
|
+
),
|
|
939
|
+
"search_history": (
|
|
940
|
+
self._search_history,
|
|
941
|
+
False,
|
|
942
|
+
lambda a: (a["query"], a.get("category"), a.get("limit", 20)),
|
|
943
|
+
),
|
|
944
|
+
"ingest_file": (
|
|
945
|
+
self._ingest_file,
|
|
946
|
+
True, # async
|
|
947
|
+
lambda a: (a["file_path"],),
|
|
948
|
+
),
|
|
949
|
+
"enrich_entity": (
|
|
950
|
+
self._enrich_entity,
|
|
951
|
+
True,
|
|
952
|
+
lambda a: (a["entity_ref"], a.get("project")),
|
|
953
|
+
),
|
|
954
|
+
"resolve_blocker": (
|
|
955
|
+
self._resolve_blocker,
|
|
956
|
+
False,
|
|
957
|
+
lambda a: (a["memory_id"],),
|
|
958
|
+
),
|
|
959
|
+
"unresolve_blocker": (
|
|
960
|
+
self._unresolve_blocker,
|
|
961
|
+
False,
|
|
962
|
+
lambda a: (a["memory_id"],),
|
|
963
|
+
),
|
|
964
|
+
"consolidate_memory": (
|
|
965
|
+
self._consolidate_memory,
|
|
966
|
+
False,
|
|
967
|
+
lambda a: (a.get("project"), a.get("days_stale", 30)),
|
|
968
|
+
),
|
|
969
|
+
"log_session": (
|
|
970
|
+
self._log_session,
|
|
971
|
+
True,
|
|
972
|
+
lambda a: (
|
|
973
|
+
a["summary"],
|
|
974
|
+
a.get("learnings", []),
|
|
975
|
+
a.get("entities", []),
|
|
976
|
+
a.get("project"),
|
|
977
|
+
),
|
|
978
|
+
),
|
|
979
|
+
"archive_memory": (
|
|
980
|
+
self._archive_memory,
|
|
981
|
+
False,
|
|
982
|
+
lambda a: (a["memory_id"], a.get("reason")),
|
|
983
|
+
),
|
|
984
|
+
"bootstrap_memory": (
|
|
985
|
+
self._bootstrap_memory,
|
|
986
|
+
True,
|
|
987
|
+
lambda a: (a.get("path"),),
|
|
988
|
+
),
|
|
989
|
+
"memory_status": (
|
|
990
|
+
self._get_status,
|
|
991
|
+
False,
|
|
992
|
+
lambda a: (),
|
|
993
|
+
),
|
|
994
|
+
"get_linked_memories": (
|
|
995
|
+
self._get_linked_memories,
|
|
996
|
+
False,
|
|
997
|
+
lambda a: (a["memory_id"], a.get("link_types")),
|
|
998
|
+
),
|
|
999
|
+
"export_memories": (
|
|
1000
|
+
self._export_memories,
|
|
1001
|
+
True,
|
|
1002
|
+
lambda a: (
|
|
1003
|
+
a.get("output_path"),
|
|
1004
|
+
a.get("project"),
|
|
1005
|
+
a.get("categories"),
|
|
1006
|
+
a.get("since_days"),
|
|
1007
|
+
a.get("include_archived", False),
|
|
1008
|
+
),
|
|
1009
|
+
),
|
|
1010
|
+
"import_memories": (
|
|
1011
|
+
self._import_memories,
|
|
1012
|
+
True,
|
|
1013
|
+
lambda a: (
|
|
1014
|
+
a["input_path"],
|
|
1015
|
+
a.get("dry_run", False),
|
|
1016
|
+
a.get("skip_duplicates", True),
|
|
1017
|
+
),
|
|
1018
|
+
),
|
|
1019
|
+
"bulk_archive": (
|
|
1020
|
+
self._bulk_archive,
|
|
1021
|
+
False,
|
|
1022
|
+
lambda a: (
|
|
1023
|
+
a.get("memory_ids"),
|
|
1024
|
+
a.get("category"),
|
|
1025
|
+
a.get("older_than_days"),
|
|
1026
|
+
a["reason"],
|
|
1027
|
+
),
|
|
1028
|
+
),
|
|
1029
|
+
"delete_memory": (
|
|
1030
|
+
self._delete_memory,
|
|
1031
|
+
True,
|
|
1032
|
+
lambda a: (a["memory_id"], a.get("also_delete_vector", True)),
|
|
1033
|
+
),
|
|
1034
|
+
"edit_memory": (
|
|
1035
|
+
self._edit_memory,
|
|
1036
|
+
True,
|
|
1037
|
+
lambda a: (
|
|
1038
|
+
a["memory_id"],
|
|
1039
|
+
a.get("content"),
|
|
1040
|
+
a.get("what"),
|
|
1041
|
+
a.get("why"),
|
|
1042
|
+
a.get("learned"),
|
|
1043
|
+
a.get("recompute_embedding", True),
|
|
1044
|
+
),
|
|
1045
|
+
),
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if name not in dispatch:
|
|
1049
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
1050
|
+
|
|
1051
|
+
handler, is_async, args_extractor = dispatch[name]
|
|
1052
|
+
extracted_args = args_extractor(args)
|
|
1053
|
+
|
|
1054
|
+
if is_async:
|
|
1055
|
+
return await handler(*extracted_args)
|
|
1056
|
+
else:
|
|
1057
|
+
return handler(*extracted_args)
|
|
1058
|
+
|
|
1059
|
+
async def _recall(
|
|
1060
|
+
self,
|
|
1061
|
+
query: str,
|
|
1062
|
+
limit: int,
|
|
1063
|
+
project: str | None = None,
|
|
1064
|
+
compact: bool = False,
|
|
1065
|
+
since_days: int | None = None,
|
|
1066
|
+
category: str | None = None,
|
|
1067
|
+
) -> dict[str, Any]:
|
|
1068
|
+
"""Search memories, optionally filtered by project, date, and category."""
|
|
1069
|
+
# Handle 'auto' to detect current project from git
|
|
1070
|
+
if project == "auto":
|
|
1071
|
+
project = _detect_current_project()
|
|
1072
|
+
logger.info(f"Auto-detected project: {project}")
|
|
1073
|
+
|
|
1074
|
+
# Fetch extra results to know how many more are available
|
|
1075
|
+
fetch_limit = limit + 20
|
|
1076
|
+
results = await self.search_engine.search_async(query, fetch_limit, project=project)
|
|
1077
|
+
|
|
1078
|
+
# Post-filter by date if specified
|
|
1079
|
+
if since_days:
|
|
1080
|
+
cutoff = datetime.now(UTC) - timedelta(days=since_days)
|
|
1081
|
+
|
|
1082
|
+
# Handle naive datetimes by assuming UTC
|
|
1083
|
+
def is_after_cutoff(mem_created_at: datetime) -> bool:
|
|
1084
|
+
if mem_created_at.tzinfo is None:
|
|
1085
|
+
mem_created_at = mem_created_at.replace(tzinfo=UTC)
|
|
1086
|
+
return mem_created_at > cutoff
|
|
1087
|
+
|
|
1088
|
+
results = [r for r in results if is_after_cutoff(r.memory.created_at)]
|
|
1089
|
+
|
|
1090
|
+
# Post-filter by category if specified
|
|
1091
|
+
if category:
|
|
1092
|
+
cat = MemoryCategory(category)
|
|
1093
|
+
results = [r for r in results if r.memory.category == cat]
|
|
1094
|
+
|
|
1095
|
+
# Track what's beyond the limit
|
|
1096
|
+
total_matches = len(results)
|
|
1097
|
+
next_result = results[limit] if len(results) > limit else None
|
|
1098
|
+
|
|
1099
|
+
# Trim to requested limit after filtering
|
|
1100
|
+
results = results[:limit]
|
|
1101
|
+
|
|
1102
|
+
# Cache the returned memories for quick subsequent access
|
|
1103
|
+
self.memory_cache.put_many([r.memory for r in results])
|
|
1104
|
+
|
|
1105
|
+
# Build pagination hint
|
|
1106
|
+
more_available = total_matches - limit if total_matches > limit else 0
|
|
1107
|
+
pagination = {"more_available": more_available}
|
|
1108
|
+
if next_result:
|
|
1109
|
+
pagination["next_score"] = round(next_result.score, 3)
|
|
1110
|
+
pagination["next_category"] = next_result.memory.category.value
|
|
1111
|
+
pagination["next_what"] = next_result.memory.what or next_result.memory.content[:40]
|
|
1112
|
+
|
|
1113
|
+
if compact:
|
|
1114
|
+
# Compact mode: truncated content, essential fields only
|
|
1115
|
+
return {
|
|
1116
|
+
"count": len(results),
|
|
1117
|
+
"pagination": pagination,
|
|
1118
|
+
"results": [
|
|
1119
|
+
{
|
|
1120
|
+
"id": r.memory.id,
|
|
1121
|
+
"content": r.memory.content[:150] + "..."
|
|
1122
|
+
if len(r.memory.content) > 150
|
|
1123
|
+
else r.memory.content,
|
|
1124
|
+
"category": r.memory.category.value,
|
|
1125
|
+
"what": r.memory.what,
|
|
1126
|
+
"learned": r.memory.learned,
|
|
1127
|
+
"age": _format_age(r.memory.created_at),
|
|
1128
|
+
}
|
|
1129
|
+
for r in results
|
|
1130
|
+
],
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
# Build results with linked memories as hints
|
|
1134
|
+
formatted_results = []
|
|
1135
|
+
prefetch_memory_ids = []
|
|
1136
|
+
|
|
1137
|
+
for r in results:
|
|
1138
|
+
result_dict = {
|
|
1139
|
+
"id": r.memory.id,
|
|
1140
|
+
"content": r.memory.content,
|
|
1141
|
+
"category": r.memory.category.value,
|
|
1142
|
+
"score": r.score,
|
|
1143
|
+
"match_type": r.match_type,
|
|
1144
|
+
"source": r.memory.source_file,
|
|
1145
|
+
"what": r.memory.what,
|
|
1146
|
+
"why": r.memory.why,
|
|
1147
|
+
"learned": r.memory.learned,
|
|
1148
|
+
"age": _format_age(r.memory.created_at),
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
# Add minimal hints about linked memories
|
|
1152
|
+
# Just enough to know there's more to explore, not the content itself
|
|
1153
|
+
if r.memory.id:
|
|
1154
|
+
linked_ids = self.sqlite.get_linked_memory_ids(r.memory.id)
|
|
1155
|
+
if linked_ids:
|
|
1156
|
+
linked_memories = self.sqlite.get_memories_by_ids(linked_ids[:5])
|
|
1157
|
+
result_dict["has_links"] = len(linked_ids)
|
|
1158
|
+
result_dict["linked"] = [
|
|
1159
|
+
{"id": m.id, "type": m.category.value, "what": m.what or m.category.value}
|
|
1160
|
+
for m in linked_memories.values()
|
|
1161
|
+
]
|
|
1162
|
+
# Schedule prefetch for memories with links
|
|
1163
|
+
prefetch_memory_ids.append(r.memory.id)
|
|
1164
|
+
|
|
1165
|
+
formatted_results.append(result_dict)
|
|
1166
|
+
|
|
1167
|
+
# Background prefetch linked memories for quick subsequent get_linked_memories calls
|
|
1168
|
+
for memory_id in prefetch_memory_ids[:5]: # Limit prefetch to top 5 results
|
|
1169
|
+
asyncio.create_task(self.memory_cache.prefetch_linked_async(memory_id))
|
|
1170
|
+
|
|
1171
|
+
return {
|
|
1172
|
+
"count": len(results),
|
|
1173
|
+
"pagination": pagination,
|
|
1174
|
+
"results": formatted_results,
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async def _remember(
|
|
1178
|
+
self,
|
|
1179
|
+
content: str,
|
|
1180
|
+
category: str,
|
|
1181
|
+
entities: list[str],
|
|
1182
|
+
what: str | None,
|
|
1183
|
+
why: str | None,
|
|
1184
|
+
learned: str | None,
|
|
1185
|
+
project: str | None = None,
|
|
1186
|
+
) -> dict[str, Any]:
|
|
1187
|
+
"""Store a memory immediately, defer embedding and dedup to background.
|
|
1188
|
+
|
|
1189
|
+
This ensures remember() returns instantly without waiting for embedding.
|
|
1190
|
+
Semantic deduplication happens async - if a near-duplicate is found,
|
|
1191
|
+
the new memory is archived with a note.
|
|
1192
|
+
"""
|
|
1193
|
+
# Auto-detect project from git if not provided
|
|
1194
|
+
if not project:
|
|
1195
|
+
project = _detect_current_project()
|
|
1196
|
+
|
|
1197
|
+
memory = Memory(
|
|
1198
|
+
category=MemoryCategory(category),
|
|
1199
|
+
content=content,
|
|
1200
|
+
project=project,
|
|
1201
|
+
what=what,
|
|
1202
|
+
why=why,
|
|
1203
|
+
learned=learned,
|
|
1204
|
+
entities=entities,
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
entity_ids = []
|
|
1208
|
+
for ref in entities:
|
|
1209
|
+
entity = self._parse_entity_ref(ref)
|
|
1210
|
+
if entity:
|
|
1211
|
+
entity_id = self.sqlite.upsert_entity(entity)
|
|
1212
|
+
entity_ids.append(entity_id)
|
|
1213
|
+
|
|
1214
|
+
# Store immediately without embedding
|
|
1215
|
+
memory_id = self.sqlite.insert_memory(memory, entity_ids)
|
|
1216
|
+
|
|
1217
|
+
# Cache the new memory immediately
|
|
1218
|
+
memory.id = memory_id
|
|
1219
|
+
self.memory_cache.put(memory)
|
|
1220
|
+
|
|
1221
|
+
# Background: compute embedding, check for duplicates, store vector
|
|
1222
|
+
async def embed_and_dedup():
|
|
1223
|
+
async with MemoryServer._embedding_semaphore:
|
|
1224
|
+
try:
|
|
1225
|
+
embedding = await self.embeddings.embed_async(memory.embedding_content())
|
|
1226
|
+
|
|
1227
|
+
# Check for semantic duplicates (0.92 threshold)
|
|
1228
|
+
similar = self.vectors.search(embedding, limit=3)
|
|
1229
|
+
for match in similar:
|
|
1230
|
+
distance = match.get("_distance", 1.0)
|
|
1231
|
+
similarity = 1.0 / (1.0 + distance)
|
|
1232
|
+
if similarity > 0.92:
|
|
1233
|
+
existing_id = match.get("memory_id")
|
|
1234
|
+
if existing_id:
|
|
1235
|
+
existing = self.sqlite.get_memory_by_id(existing_id)
|
|
1236
|
+
if existing and existing.category == memory.category:
|
|
1237
|
+
# Archive the new memory as duplicate
|
|
1238
|
+
self.sqlite.archive_memory(
|
|
1239
|
+
memory_id,
|
|
1240
|
+
f"Duplicate of memory {existing_id} (similarity: {similarity:.3f})",
|
|
1241
|
+
)
|
|
1242
|
+
logger.info(
|
|
1243
|
+
f"Memory {memory_id} archived as duplicate of {existing_id}"
|
|
1244
|
+
)
|
|
1245
|
+
return
|
|
1246
|
+
|
|
1247
|
+
# No duplicate found, store the embedding
|
|
1248
|
+
self.vectors.add(
|
|
1249
|
+
f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding
|
|
1250
|
+
)
|
|
1251
|
+
except Exception as e:
|
|
1252
|
+
logger.warning(f"Failed to embed memory {memory_id}: {e}")
|
|
1253
|
+
|
|
1254
|
+
# Queue the embedding task, but limit queue depth
|
|
1255
|
+
if len(MemoryServer._pending_embeddings) >= MAX_PENDING_EMBEDDINGS:
|
|
1256
|
+
logger.warning("Embedding queue full, running synchronously")
|
|
1257
|
+
asyncio.create_task(embed_and_dedup()) # Still async but logged
|
|
1258
|
+
else:
|
|
1259
|
+
task = asyncio.create_task(embed_and_dedup())
|
|
1260
|
+
MemoryServer._pending_embeddings.add(task)
|
|
1261
|
+
task.add_done_callback(MemoryServer._pending_embeddings.discard)
|
|
1262
|
+
|
|
1263
|
+
return {"status": "stored", "memory_id": memory_id}
|
|
1264
|
+
|
|
1265
|
+
async def _get_context(self, entity_ref: str) -> dict[str, Any]:
|
|
1266
|
+
"""Get all context for an entity.
|
|
1267
|
+
|
|
1268
|
+
Combines two sources:
|
|
1269
|
+
1. Memories explicitly tagged with the entity
|
|
1270
|
+
2. Semantic search for memories mentioning the entity ref
|
|
1271
|
+
"""
|
|
1272
|
+
entity = self._parse_entity_ref(entity_ref)
|
|
1273
|
+
if not entity:
|
|
1274
|
+
return {"error": f"Could not parse entity reference: {entity_ref}"}
|
|
1275
|
+
|
|
1276
|
+
db_entity = self.sqlite.get_entity(entity.ref, entity.type)
|
|
1277
|
+
|
|
1278
|
+
# Get explicitly tagged memories
|
|
1279
|
+
tagged_memories: list[Memory] = []
|
|
1280
|
+
tagged_ids: set[int] = set()
|
|
1281
|
+
if db_entity and db_entity.id is not None:
|
|
1282
|
+
tagged_memories = self.sqlite.get_memories_for_entity(db_entity.id)
|
|
1283
|
+
tagged_ids = {m.id for m in tagged_memories if m.id is not None}
|
|
1284
|
+
|
|
1285
|
+
# Also do semantic search for the entity ref to find mentions
|
|
1286
|
+
# This catches memories that mention the entity but weren't explicitly tagged
|
|
1287
|
+
recall_results = await self.search_engine.search_async(entity_ref, limit=10)
|
|
1288
|
+
related_memories = [
|
|
1289
|
+
r.memory
|
|
1290
|
+
for r in recall_results
|
|
1291
|
+
if r.memory.id not in tagged_ids # Avoid duplicates
|
|
1292
|
+
]
|
|
1293
|
+
|
|
1294
|
+
entity_info: dict[str, Any] = {
|
|
1295
|
+
"type": entity.type.value,
|
|
1296
|
+
"ref": entity.ref,
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if db_entity:
|
|
1300
|
+
entity_info["title"] = db_entity.title
|
|
1301
|
+
if db_entity.metadata:
|
|
1302
|
+
entity_info["state"] = db_entity.metadata.get("state")
|
|
1303
|
+
entity_info["author"] = db_entity.metadata.get("author")
|
|
1304
|
+
entity_info["web_url"] = db_entity.metadata.get("web_url")
|
|
1305
|
+
if db_entity.metadata.get("labels"):
|
|
1306
|
+
entity_info["labels"] = db_entity.metadata["labels"][:5]
|
|
1307
|
+
|
|
1308
|
+
# Helper to format a memory with optional linked hints
|
|
1309
|
+
def format_memory(m: Memory, compact: bool = False) -> dict[str, Any]:
|
|
1310
|
+
mem_dict: dict[str, Any] = {
|
|
1311
|
+
"id": m.id,
|
|
1312
|
+
"content": m.content[:200] + "..."
|
|
1313
|
+
if compact and len(m.content) > 200
|
|
1314
|
+
else m.content,
|
|
1315
|
+
"category": m.category.value,
|
|
1316
|
+
"what": m.what,
|
|
1317
|
+
"learned": m.learned,
|
|
1318
|
+
"age": _format_age(m.created_at),
|
|
1319
|
+
}
|
|
1320
|
+
if not compact:
|
|
1321
|
+
mem_dict["why"] = m.why
|
|
1322
|
+
|
|
1323
|
+
# Add linked memory hints if available
|
|
1324
|
+
if m.id:
|
|
1325
|
+
linked_ids = self.sqlite.get_linked_memory_ids(m.id)
|
|
1326
|
+
if linked_ids:
|
|
1327
|
+
linked_memories = self.sqlite.get_memories_by_ids(linked_ids[:3])
|
|
1328
|
+
mem_dict["has_links"] = len(linked_ids)
|
|
1329
|
+
mem_dict["linked"] = [
|
|
1330
|
+
{
|
|
1331
|
+
"id": lm.id,
|
|
1332
|
+
"type": lm.category.value,
|
|
1333
|
+
"what": lm.what or lm.category.value,
|
|
1334
|
+
}
|
|
1335
|
+
for lm in linked_memories.values()
|
|
1336
|
+
]
|
|
1337
|
+
|
|
1338
|
+
return mem_dict
|
|
1339
|
+
|
|
1340
|
+
result: dict[str, Any] = {
|
|
1341
|
+
"entity": entity_info,
|
|
1342
|
+
"memories": [format_memory(m, compact=False) for m in tagged_memories],
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
# Add related memories from semantic search (if any)
|
|
1346
|
+
# Use compact format to avoid overwhelming output
|
|
1347
|
+
if related_memories:
|
|
1348
|
+
result["related"] = [format_memory(m, compact=True) for m in related_memories[:5]]
|
|
1349
|
+
|
|
1350
|
+
if not tagged_memories and not related_memories:
|
|
1351
|
+
result["note"] = "No memories found"
|
|
1352
|
+
|
|
1353
|
+
return result
|
|
1354
|
+
|
|
1355
|
+
def _get_active_sessions(self) -> dict[str, Any]:
|
|
1356
|
+
"""Get active sessions."""
|
|
1357
|
+
sessions = self.session_registry.get_active_sessions()
|
|
1358
|
+
return {
|
|
1359
|
+
"count": len(sessions),
|
|
1360
|
+
"sessions": [
|
|
1361
|
+
{
|
|
1362
|
+
"id": s.id,
|
|
1363
|
+
"started_at": s.started_at.isoformat(),
|
|
1364
|
+
"last_heartbeat": s.last_heartbeat.isoformat(),
|
|
1365
|
+
"working_on": s.working_on,
|
|
1366
|
+
"claimed_items": s.claimed_items,
|
|
1367
|
+
}
|
|
1368
|
+
for s in sessions
|
|
1369
|
+
],
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
def _session_start(self, session_id: str, working_on: str | None) -> dict[str, Any]:
|
|
1373
|
+
"""Start a session."""
|
|
1374
|
+
session = self.session_registry.register(session_id, working_on)
|
|
1375
|
+
return {
|
|
1376
|
+
"status": "registered",
|
|
1377
|
+
"session_id": session.id,
|
|
1378
|
+
"started_at": session.started_at.isoformat(),
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
def _session_heartbeat(self, session_id: str) -> dict[str, Any]:
|
|
1382
|
+
"""Update session heartbeat."""
|
|
1383
|
+
self.session_registry.heartbeat(session_id)
|
|
1384
|
+
return {"status": "ok", "session_id": session_id}
|
|
1385
|
+
|
|
1386
|
+
async def _session_end(self, session_id: str, summary: str | None) -> dict[str, Any]:
|
|
1387
|
+
"""End a session."""
|
|
1388
|
+
if summary:
|
|
1389
|
+
await self._remember(
|
|
1390
|
+
summary,
|
|
1391
|
+
"conversation",
|
|
1392
|
+
[],
|
|
1393
|
+
f"Session {session_id} summary",
|
|
1394
|
+
None,
|
|
1395
|
+
None,
|
|
1396
|
+
)
|
|
1397
|
+
self.session_registry.unregister(session_id)
|
|
1398
|
+
return {"status": "ended", "session_id": session_id}
|
|
1399
|
+
|
|
1400
|
+
def _claim_item(self, session_id: str, item_ref: str) -> dict[str, Any]:
|
|
1401
|
+
"""Claim an item."""
|
|
1402
|
+
success = self.session_registry.claim_item(session_id, item_ref)
|
|
1403
|
+
if success:
|
|
1404
|
+
return {"status": "claimed", "item": item_ref, "session": session_id}
|
|
1405
|
+
else:
|
|
1406
|
+
owner = self.session_registry.get_item_owner(item_ref)
|
|
1407
|
+
return {
|
|
1408
|
+
"status": "already_claimed",
|
|
1409
|
+
"item": item_ref,
|
|
1410
|
+
"owner": owner,
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
def _release_item(self, session_id: str, item_ref: str) -> dict[str, Any]:
|
|
1414
|
+
"""Release a claimed item."""
|
|
1415
|
+
self.session_registry.release_item(session_id, item_ref)
|
|
1416
|
+
return {"status": "released", "item": item_ref}
|
|
1417
|
+
|
|
1418
|
+
def _get_boot_context(self) -> dict[str, Any]:
|
|
1419
|
+
"""Get boot context with comprehensive session startup information."""
|
|
1420
|
+
boot = BootContext()
|
|
1421
|
+
|
|
1422
|
+
if self.config.boot.identity:
|
|
1423
|
+
# Use config values, falling back to auto-detection
|
|
1424
|
+
user = self.config.identity.user
|
|
1425
|
+
primary_project = self.config.identity.primary_project
|
|
1426
|
+
|
|
1427
|
+
# Auto-detect from git if not configured
|
|
1428
|
+
if not user or not primary_project:
|
|
1429
|
+
detected_project = _detect_current_project()
|
|
1430
|
+
if detected_project and not primary_project:
|
|
1431
|
+
primary_project = detected_project
|
|
1432
|
+
|
|
1433
|
+
boot.identity = {
|
|
1434
|
+
"user": user,
|
|
1435
|
+
"instance": self.config.identity.instance,
|
|
1436
|
+
"primary_project": primary_project,
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if self.config.boot.active_sessions:
|
|
1440
|
+
boot.active_sessions = self.session_registry.get_active_sessions()
|
|
1441
|
+
|
|
1442
|
+
if self.config.boot.unresolved_blockers:
|
|
1443
|
+
boot.unresolved_blockers = self.sqlite.get_memories_by_category(
|
|
1444
|
+
MemoryCategory.BLOCKER, limit=10
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
if self.config.boot.recent_decisions:
|
|
1448
|
+
boot.recent_decisions = self.sqlite.get_memories_by_category(
|
|
1449
|
+
MemoryCategory.DECISION, limit=5
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
# Load directives contextually: global + current project specific
|
|
1453
|
+
current_project = _detect_current_project()
|
|
1454
|
+
boot.directives = self.sqlite.get_directives_for_context(current_project, limit=10)
|
|
1455
|
+
|
|
1456
|
+
recent_sessions = self._get_recent_session_summaries(limit=3)
|
|
1457
|
+
|
|
1458
|
+
hot_entities = self._get_hot_entities(limit=5)
|
|
1459
|
+
|
|
1460
|
+
procedures = self.sqlite.get_memories_by_category(MemoryCategory.PROCEDURE, limit=3)
|
|
1461
|
+
|
|
1462
|
+
# Get plan summaries (lightweight - just project/count/titles)
|
|
1463
|
+
plan_summaries = self.sqlite.get_plan_summaries()
|
|
1464
|
+
|
|
1465
|
+
return {
|
|
1466
|
+
"identity": boot.identity,
|
|
1467
|
+
"active_sessions": [
|
|
1468
|
+
{
|
|
1469
|
+
"id": s.id,
|
|
1470
|
+
"working_on": s.working_on,
|
|
1471
|
+
"claimed_items": s.claimed_items,
|
|
1472
|
+
}
|
|
1473
|
+
for s in boot.active_sessions
|
|
1474
|
+
],
|
|
1475
|
+
"unresolved_blockers": [
|
|
1476
|
+
{"content": m.content, "entities": m.entities, "age": _format_age(m.created_at)}
|
|
1477
|
+
for m in boot.unresolved_blockers
|
|
1478
|
+
],
|
|
1479
|
+
"recent_decisions": [
|
|
1480
|
+
{"content": m.content, "what": m.what, "age": _format_age(m.created_at)}
|
|
1481
|
+
for m in boot.recent_decisions
|
|
1482
|
+
],
|
|
1483
|
+
"directives": [
|
|
1484
|
+
{
|
|
1485
|
+
"content": m.content,
|
|
1486
|
+
"what": m.what,
|
|
1487
|
+
"scope": m.project or "global",
|
|
1488
|
+
"age": _format_age(m.created_at),
|
|
1489
|
+
}
|
|
1490
|
+
for m in boot.directives
|
|
1491
|
+
],
|
|
1492
|
+
"recent_sessions": recent_sessions,
|
|
1493
|
+
"hot_entities": hot_entities,
|
|
1494
|
+
"key_procedures": [
|
|
1495
|
+
{"content": m.content[:200], "what": m.what, "age": _format_age(m.created_at)}
|
|
1496
|
+
for m in procedures
|
|
1497
|
+
],
|
|
1498
|
+
"active_plans": plan_summaries if plan_summaries else None,
|
|
1499
|
+
"remember_prompts": {
|
|
1500
|
+
"instruction": "ACTIVELY USE MEMORY during this session. Don't wait to be asked.",
|
|
1501
|
+
"when_to_remember": [
|
|
1502
|
+
"Decision made? → remember(category='decision', what='chose X', why='because Y')",
|
|
1503
|
+
"Hit a blocker? → remember(category='blocker', what='blocked by X')",
|
|
1504
|
+
"Learned something? → remember(category='procedure', what='how to X', learned='key insight')",
|
|
1505
|
+
"Interesting fact? → remember(category='fact', what='X does Y')",
|
|
1506
|
+
"Long-term goal? → remember(category='plan', what='goal X', why='to achieve Y')",
|
|
1507
|
+
],
|
|
1508
|
+
"tips": [
|
|
1509
|
+
"Include entity refs (!MR, #issue, &epic) to link memories",
|
|
1510
|
+
"The 'learned' field captures the key takeaway for future sessions",
|
|
1511
|
+
"Blockers can be resolved later with resolve_blocker(memory_id)",
|
|
1512
|
+
],
|
|
1513
|
+
},
|
|
1514
|
+
"linked_memories": {
|
|
1515
|
+
"what": "Search results include 'linked' hints showing related memories you didn't search for",
|
|
1516
|
+
"when_to_fetch": [
|
|
1517
|
+
"type='directive' → ALWAYS fetch, these are standing instructions",
|
|
1518
|
+
"type='plan' → fetch if relevant to current work context",
|
|
1519
|
+
"type='decision' → fetch to avoid contradicting prior choices",
|
|
1520
|
+
"type='blocker' → fetch if working on related entity",
|
|
1521
|
+
"type='procedure' → fetch if about to do that task",
|
|
1522
|
+
],
|
|
1523
|
+
"how": "Call get_linked_memories(memory_id) to fetch full content",
|
|
1524
|
+
},
|
|
1525
|
+
"plans_hint": "Use recall(query, category='plan') to load full plan details"
|
|
1526
|
+
if plan_summaries
|
|
1527
|
+
else None,
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
def _get_recent_session_summaries(self, limit: int = 3) -> list[dict[str, Any]]:
|
|
1531
|
+
"""Get summaries of recent conversation sessions."""
|
|
1532
|
+
recent = self.sqlite.get_memories_by_category(MemoryCategory.CONVERSATION, limit=limit)
|
|
1533
|
+
summaries = []
|
|
1534
|
+
for m in recent:
|
|
1535
|
+
lines = m.content.split("\n")
|
|
1536
|
+
title = lines[0].replace("Session: ", "") if lines else "Unknown"
|
|
1537
|
+
topics = ""
|
|
1538
|
+
outcome = ""
|
|
1539
|
+
for line in lines:
|
|
1540
|
+
if line.startswith("Topics:"):
|
|
1541
|
+
topics = line.replace("Topics:", "").strip()
|
|
1542
|
+
elif line.startswith("Outcome:"):
|
|
1543
|
+
outcome = line.replace("Outcome:", "").strip()
|
|
1544
|
+
|
|
1545
|
+
summaries.append(
|
|
1546
|
+
{
|
|
1547
|
+
"title": title,
|
|
1548
|
+
"topics": topics,
|
|
1549
|
+
"outcome": outcome,
|
|
1550
|
+
"when": m.created_at.isoformat(),
|
|
1551
|
+
}
|
|
1552
|
+
)
|
|
1553
|
+
return summaries
|
|
1554
|
+
|
|
1555
|
+
def _get_hot_entities(self, limit: int = 5) -> list[dict[str, Any]]:
|
|
1556
|
+
"""Get entities with most recent activity/mentions."""
|
|
1557
|
+
from collections import Counter
|
|
1558
|
+
|
|
1559
|
+
recent_memories = self.sqlite.get_memories_by_category(
|
|
1560
|
+
MemoryCategory.CONVERSATION, limit=20
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
entity_counts: Counter[str] = Counter()
|
|
1564
|
+
for m in recent_memories:
|
|
1565
|
+
for entity in m.entities:
|
|
1566
|
+
entity_counts[entity] += 1
|
|
1567
|
+
|
|
1568
|
+
hot = []
|
|
1569
|
+
for ref, count in entity_counts.most_common(limit):
|
|
1570
|
+
entity = self._parse_entity_ref(ref)
|
|
1571
|
+
if entity:
|
|
1572
|
+
db_entity = self.sqlite.get_entity(entity.ref, entity.type)
|
|
1573
|
+
hot.append(
|
|
1574
|
+
{
|
|
1575
|
+
"ref": ref,
|
|
1576
|
+
"type": entity.type.value,
|
|
1577
|
+
"title": db_entity.title if db_entity else None,
|
|
1578
|
+
"mentions": count,
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
return hot
|
|
1583
|
+
|
|
1584
|
+
def _search_history(self, query: str, category: str | None, limit: int) -> dict[str, Any]:
|
|
1585
|
+
"""Search history with optional category filter."""
|
|
1586
|
+
if category:
|
|
1587
|
+
memories = self.sqlite.get_memories_by_category(MemoryCategory(category), limit)
|
|
1588
|
+
filtered = [m for m in memories if query.lower() in m.content.lower()]
|
|
1589
|
+
return {
|
|
1590
|
+
"count": len(filtered),
|
|
1591
|
+
"results": [
|
|
1592
|
+
{
|
|
1593
|
+
"content": m.content,
|
|
1594
|
+
"category": m.category.value,
|
|
1595
|
+
"what": m.what,
|
|
1596
|
+
"age": _format_age(m.created_at),
|
|
1597
|
+
}
|
|
1598
|
+
for m in filtered
|
|
1599
|
+
],
|
|
1600
|
+
}
|
|
1601
|
+
else:
|
|
1602
|
+
return self._recall(query, limit)
|
|
1603
|
+
|
|
1604
|
+
async def _ingest_file(self, file_path: str) -> dict[str, Any]:
|
|
1605
|
+
"""Ingest a file manually.
|
|
1606
|
+
|
|
1607
|
+
Creates memories for each chunk and links them sequentially
|
|
1608
|
+
with strong (0.95) SEQUENCE links to preserve document structure.
|
|
1609
|
+
Embeddings are created asynchronously to avoid blocking.
|
|
1610
|
+
"""
|
|
1611
|
+
path = Path(file_path).expanduser()
|
|
1612
|
+
if not path.exists():
|
|
1613
|
+
return {"error": f"File not found: {file_path}"}
|
|
1614
|
+
|
|
1615
|
+
doc = self.parser.parse_file(path)
|
|
1616
|
+
|
|
1617
|
+
entity_ids = []
|
|
1618
|
+
for entity_type, ref in doc.entities:
|
|
1619
|
+
entity = Entity(type=entity_type, ref=ref)
|
|
1620
|
+
entity_id = self.sqlite.upsert_entity(entity)
|
|
1621
|
+
entity_ids.append(entity_id)
|
|
1622
|
+
|
|
1623
|
+
# Insert memories first (fast), then embed asynchronously
|
|
1624
|
+
memory_ids: list[int] = []
|
|
1625
|
+
memories_to_embed: list[tuple[int, str]] = []
|
|
1626
|
+
|
|
1627
|
+
for memory in doc.memories:
|
|
1628
|
+
memory_id = self.sqlite.insert_memory(memory, entity_ids)
|
|
1629
|
+
memory.id = memory_id
|
|
1630
|
+
self.memory_cache.put(memory)
|
|
1631
|
+
memory_ids.append(memory_id)
|
|
1632
|
+
memories_to_embed.append((memory_id, memory.embedding_content()))
|
|
1633
|
+
|
|
1634
|
+
# Create bidirectional sequential links between chunks from the same file
|
|
1635
|
+
# This allows navigation up/down the document from any chunk
|
|
1636
|
+
links_created = 0
|
|
1637
|
+
for i in range(len(memory_ids) - 1):
|
|
1638
|
+
# Forward link: chunk i → chunk i+1
|
|
1639
|
+
forward_link = MemoryLink(
|
|
1640
|
+
source_memory_id=memory_ids[i],
|
|
1641
|
+
target_memory_id=memory_ids[i + 1],
|
|
1642
|
+
link_type=LinkType.SEQUENCE,
|
|
1643
|
+
strength=0.95, # Strong link - same document
|
|
1644
|
+
reason=f"Next section in {path.name}",
|
|
1645
|
+
)
|
|
1646
|
+
if self.sqlite.insert_link(forward_link):
|
|
1647
|
+
links_created += 1
|
|
1648
|
+
|
|
1649
|
+
# Backward link: chunk i+1 → chunk i
|
|
1650
|
+
backward_link = MemoryLink(
|
|
1651
|
+
source_memory_id=memory_ids[i + 1],
|
|
1652
|
+
target_memory_id=memory_ids[i],
|
|
1653
|
+
link_type=LinkType.SEQUENCE,
|
|
1654
|
+
strength=0.95, # Strong link - same document
|
|
1655
|
+
reason=f"Previous section in {path.name}",
|
|
1656
|
+
)
|
|
1657
|
+
if self.sqlite.insert_link(backward_link):
|
|
1658
|
+
links_created += 1
|
|
1659
|
+
|
|
1660
|
+
# Embed asynchronously - don't block the response
|
|
1661
|
+
async def embed_memories():
|
|
1662
|
+
for memory_id, content in memories_to_embed:
|
|
1663
|
+
try:
|
|
1664
|
+
embedding = await self.embeddings.embed_async(content)
|
|
1665
|
+
self.vectors.add(f"mem_{memory_id}", memory_id, content, embedding)
|
|
1666
|
+
except Exception as e:
|
|
1667
|
+
logger.warning(f"Failed to embed memory {memory_id}: {e}")
|
|
1668
|
+
|
|
1669
|
+
# Start embedding in background
|
|
1670
|
+
asyncio.create_task(embed_memories())
|
|
1671
|
+
|
|
1672
|
+
return {
|
|
1673
|
+
"status": "ingested",
|
|
1674
|
+
"file": str(path),
|
|
1675
|
+
"entities_found": len(doc.entities),
|
|
1676
|
+
"memories_created": len(memory_ids),
|
|
1677
|
+
"sequential_links": links_created,
|
|
1678
|
+
"note": "Embeddings processing in background",
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
def _parse_entity_ref(self, ref: str) -> Entity | None:
|
|
1682
|
+
"""Parse an entity reference string."""
|
|
1683
|
+
return Entity.from_ref(ref)
|
|
1684
|
+
|
|
1685
|
+
async def _enrich_entity(self, entity_ref: str, project: str | None = None) -> dict[str, Any]:
|
|
1686
|
+
"""Enrich an entity with GitLab metadata."""
|
|
1687
|
+
entity = self._parse_entity_ref(entity_ref)
|
|
1688
|
+
if not entity:
|
|
1689
|
+
return {"error": f"Could not parse entity reference: {entity_ref}"}
|
|
1690
|
+
|
|
1691
|
+
entity.project = project
|
|
1692
|
+
|
|
1693
|
+
enriched = await self.enricher.enrich_entity(entity)
|
|
1694
|
+
self.sqlite.upsert_entity(enriched)
|
|
1695
|
+
|
|
1696
|
+
return {
|
|
1697
|
+
"status": "enriched",
|
|
1698
|
+
"entity": {
|
|
1699
|
+
"type": enriched.type.value,
|
|
1700
|
+
"ref": enriched.ref,
|
|
1701
|
+
"title": enriched.title,
|
|
1702
|
+
"project": enriched.project,
|
|
1703
|
+
"metadata": enriched.metadata,
|
|
1704
|
+
},
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
def _resolve_blocker(self, memory_id: int) -> dict[str, Any]:
|
|
1708
|
+
"""Mark a blocker as resolved."""
|
|
1709
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
1710
|
+
if not memory:
|
|
1711
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
1712
|
+
if memory.category != MemoryCategory.BLOCKER:
|
|
1713
|
+
return {"error": f"Memory {memory_id} is not a blocker (is {memory.category.value})"}
|
|
1714
|
+
if memory.resolved_at:
|
|
1715
|
+
return {"status": "already_resolved", "memory_id": memory_id}
|
|
1716
|
+
|
|
1717
|
+
success = self.sqlite.resolve_memory(memory_id)
|
|
1718
|
+
if success:
|
|
1719
|
+
return {"status": "resolved", "memory_id": memory_id}
|
|
1720
|
+
return {"error": "Failed to resolve memory"}
|
|
1721
|
+
|
|
1722
|
+
def _unresolve_blocker(self, memory_id: int) -> dict[str, Any]:
|
|
1723
|
+
"""Mark a blocker as unresolved."""
|
|
1724
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
1725
|
+
if not memory:
|
|
1726
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
1727
|
+
|
|
1728
|
+
success = self.sqlite.unresolve_memory(memory_id)
|
|
1729
|
+
if success:
|
|
1730
|
+
return {"status": "unresolved", "memory_id": memory_id}
|
|
1731
|
+
return {"error": "Failed to unresolve memory"}
|
|
1732
|
+
|
|
1733
|
+
def _consolidate_memory(self, project: str | None, days_stale: int) -> dict[str, Any]:
|
|
1734
|
+
"""Queue memory consolidation analysis to run in background.
|
|
1735
|
+
|
|
1736
|
+
Returns immediately with status. Results are stored as a 'fact' memory
|
|
1737
|
+
that can be retrieved via recall("consolidation report").
|
|
1738
|
+
"""
|
|
1739
|
+
if project == "auto":
|
|
1740
|
+
project = _detect_current_project()
|
|
1741
|
+
|
|
1742
|
+
# Check for recent consolidation report
|
|
1743
|
+
recent_report = self.sqlite.get_recent_consolidation_report()
|
|
1744
|
+
if recent_report:
|
|
1745
|
+
return {
|
|
1746
|
+
"status": "recent_report_available",
|
|
1747
|
+
"report_age": _format_age(recent_report.created_at),
|
|
1748
|
+
"memory_id": recent_report.id,
|
|
1749
|
+
"hint": "Use recall('consolidation report') or get_linked_memories to view",
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
# Queue background analysis
|
|
1753
|
+
async def run_consolidation():
|
|
1754
|
+
try:
|
|
1755
|
+
stats = self.sqlite.get_consolidation_stats(project, days_stale)
|
|
1756
|
+
|
|
1757
|
+
# Duplicate detection in background
|
|
1758
|
+
recent_memories = self.sqlite.get_recent_memories_for_dedup(
|
|
1759
|
+
project=project, limit=200, days=min(days_stale, 14)
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
potential_duplicates = []
|
|
1763
|
+
content_words: dict[int, set[str]] = {}
|
|
1764
|
+
for m in recent_memories:
|
|
1765
|
+
words = set(re.findall(r"\w{4,}", m.content.lower()))
|
|
1766
|
+
content_words[m.id] = words
|
|
1767
|
+
|
|
1768
|
+
for i, m1 in enumerate(recent_memories):
|
|
1769
|
+
for m2 in recent_memories[i + 1 : i + 31]:
|
|
1770
|
+
words1 = content_words.get(m1.id, set())
|
|
1771
|
+
words2 = content_words.get(m2.id, set())
|
|
1772
|
+
if not words1 or not words2:
|
|
1773
|
+
continue
|
|
1774
|
+
|
|
1775
|
+
overlap = len(words1 & words2) / min(len(words1), len(words2))
|
|
1776
|
+
if overlap > 0.7:
|
|
1777
|
+
potential_duplicates.append(
|
|
1778
|
+
{
|
|
1779
|
+
"ids": [m1.id, m2.id],
|
|
1780
|
+
"similarity": round(overlap, 2),
|
|
1781
|
+
}
|
|
1782
|
+
)
|
|
1783
|
+
if len(potential_duplicates) >= 10:
|
|
1784
|
+
break
|
|
1785
|
+
if len(potential_duplicates) >= 10:
|
|
1786
|
+
break
|
|
1787
|
+
|
|
1788
|
+
# Build report content
|
|
1789
|
+
stale_summary = "\n".join(
|
|
1790
|
+
f"- [{r['id']}] {r['category']}: {r['content'][:60]}... ({r['age']})"
|
|
1791
|
+
for r in stats["stale_records"][:10]
|
|
1792
|
+
)
|
|
1793
|
+
dup_summary = "\n".join(
|
|
1794
|
+
f"- IDs {d['ids']}: {d['similarity']:.0%} similar"
|
|
1795
|
+
for d in potential_duplicates[:10]
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
report_content = f"""Memory Consolidation Report ({datetime.now(UTC).strftime("%Y-%m-%d %H:%M")})
|
|
1799
|
+
|
|
1800
|
+
Total: {stats["total"]} memories
|
|
1801
|
+
By category: {stats["by_category"]}
|
|
1802
|
+
|
|
1803
|
+
Stale records ({len(stats["stale_records"])} found, showing 10):
|
|
1804
|
+
{stale_summary or "(none)"}
|
|
1805
|
+
|
|
1806
|
+
Potential duplicates ({len(potential_duplicates)} found):
|
|
1807
|
+
{dup_summary or "(none)"}
|
|
1808
|
+
|
|
1809
|
+
Actions:
|
|
1810
|
+
- archive_memory(id, reason) to archive stale records
|
|
1811
|
+
- delete_memory(id) to remove duplicates
|
|
1812
|
+
- resolve_blocker(id) to mark resolved blockers"""
|
|
1813
|
+
|
|
1814
|
+
# Store as fact memory
|
|
1815
|
+
report_memory = Memory(
|
|
1816
|
+
category=MemoryCategory.FACT,
|
|
1817
|
+
content=report_content,
|
|
1818
|
+
what="Memory consolidation report",
|
|
1819
|
+
why="Periodic health check of memory system",
|
|
1820
|
+
project=project,
|
|
1821
|
+
expires_at=datetime.now(UTC) + timedelta(days=7), # Auto-expire after 7 days
|
|
1822
|
+
)
|
|
1823
|
+
memory_id = self.sqlite.insert_memory(report_memory)
|
|
1824
|
+
report_memory.id = memory_id
|
|
1825
|
+
self.memory_cache.put(report_memory)
|
|
1826
|
+
|
|
1827
|
+
logger.info(f"Consolidation report stored as memory {memory_id}")
|
|
1828
|
+
|
|
1829
|
+
except Exception as e:
|
|
1830
|
+
logger.exception(f"Consolidation failed: {e}")
|
|
1831
|
+
|
|
1832
|
+
task = asyncio.create_task(run_consolidation())
|
|
1833
|
+
task_id = _background_tasks.register("consolidate_memory", task)
|
|
1834
|
+
|
|
1835
|
+
return {
|
|
1836
|
+
"status": "queued",
|
|
1837
|
+
"task_id": task_id,
|
|
1838
|
+
"message": "Consolidation analysis running in background",
|
|
1839
|
+
"hint": "Results will be stored as a memory. Use recall('consolidation report') to retrieve.",
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async def _log_session(
|
|
1843
|
+
self,
|
|
1844
|
+
summary: str,
|
|
1845
|
+
learnings: list[str],
|
|
1846
|
+
entities: list[str],
|
|
1847
|
+
project: str | None,
|
|
1848
|
+
) -> dict[str, Any]:
|
|
1849
|
+
"""Log a session summary with learnings."""
|
|
1850
|
+
if project == "auto" or project is None:
|
|
1851
|
+
project = _detect_current_project()
|
|
1852
|
+
|
|
1853
|
+
# Build content from summary and learnings
|
|
1854
|
+
content_parts = [f"Session Summary: {summary}"]
|
|
1855
|
+
if learnings:
|
|
1856
|
+
content_parts.append("Learnings:")
|
|
1857
|
+
for learning in learnings:
|
|
1858
|
+
content_parts.append(f"- {learning}")
|
|
1859
|
+
|
|
1860
|
+
content = "\n".join(content_parts)
|
|
1861
|
+
|
|
1862
|
+
# Store as a conversation memory
|
|
1863
|
+
memory = Memory(
|
|
1864
|
+
category=MemoryCategory.CONVERSATION,
|
|
1865
|
+
content=content,
|
|
1866
|
+
what=summary[:100] if len(summary) > 100 else summary,
|
|
1867
|
+
learned=learnings[0] if learnings else None,
|
|
1868
|
+
project=project,
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
# Parse entity refs
|
|
1872
|
+
entity_ids = []
|
|
1873
|
+
for ref in entities:
|
|
1874
|
+
entity = self._parse_entity_ref(ref)
|
|
1875
|
+
if entity:
|
|
1876
|
+
entity_id = self.sqlite.upsert_entity(entity)
|
|
1877
|
+
entity_ids.append(entity_id)
|
|
1878
|
+
|
|
1879
|
+
memory_id = self.sqlite.insert_memory(memory, entity_ids)
|
|
1880
|
+
|
|
1881
|
+
# Cache immediately
|
|
1882
|
+
memory.id = memory_id
|
|
1883
|
+
self.memory_cache.put(memory)
|
|
1884
|
+
|
|
1885
|
+
# Generate embedding in background with bounded concurrency
|
|
1886
|
+
async def embed_later():
|
|
1887
|
+
async with MemoryServer._embedding_semaphore:
|
|
1888
|
+
try:
|
|
1889
|
+
embedding = await self.embeddings.embed_async(memory.embedding_content())
|
|
1890
|
+
self.vectors.add(
|
|
1891
|
+
f"mem_{memory_id}", memory_id, memory.embedding_content(), embedding
|
|
1892
|
+
)
|
|
1893
|
+
except Exception as e:
|
|
1894
|
+
logger.warning(f"Failed to embed session log: {e}")
|
|
1895
|
+
|
|
1896
|
+
task = asyncio.create_task(embed_later())
|
|
1897
|
+
MemoryServer._pending_embeddings.add(task)
|
|
1898
|
+
task.add_done_callback(MemoryServer._pending_embeddings.discard)
|
|
1899
|
+
|
|
1900
|
+
return {
|
|
1901
|
+
"status": "logged",
|
|
1902
|
+
"memory_id": memory_id,
|
|
1903
|
+
"summary": summary,
|
|
1904
|
+
"learnings_count": len(learnings),
|
|
1905
|
+
"entities_linked": len(entity_ids),
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
def _get_status(self) -> dict[str, Any]:
|
|
1909
|
+
"""Get memory system status for introspection."""
|
|
1910
|
+
# Pending embeddings queue
|
|
1911
|
+
pending_count = len(MemoryServer._pending_embeddings)
|
|
1912
|
+
semaphore_available = (
|
|
1913
|
+
MemoryServer._embedding_semaphore._value if MemoryServer._embedding_semaphore else 4
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
# Storage stats
|
|
1917
|
+
db_size = self.config.db_path.stat().st_size if self.config.db_path.exists() else 0
|
|
1918
|
+
vectors_size = (
|
|
1919
|
+
sum(f.stat().st_size for f in self.config.vectors_path.rglob("*") if f.is_file())
|
|
1920
|
+
if self.config.vectors_path.exists()
|
|
1921
|
+
else 0
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# Vector version count
|
|
1925
|
+
try:
|
|
1926
|
+
table = self.vectors.db.open_table("memories")
|
|
1927
|
+
vector_versions = len(table.list_versions())
|
|
1928
|
+
except Exception:
|
|
1929
|
+
vector_versions = None
|
|
1930
|
+
|
|
1931
|
+
# Memory counts by category
|
|
1932
|
+
category_counts = {}
|
|
1933
|
+
for cat in MemoryCategory:
|
|
1934
|
+
with self.sqlite._get_conn() as conn:
|
|
1935
|
+
row = conn.execute(
|
|
1936
|
+
"SELECT COUNT(*) FROM memories WHERE category = ? AND resolved_at IS NULL",
|
|
1937
|
+
(cat.value,),
|
|
1938
|
+
).fetchone()
|
|
1939
|
+
category_counts[cat.value] = row[0] if row else 0
|
|
1940
|
+
|
|
1941
|
+
# Daemon status
|
|
1942
|
+
daemon_status = None
|
|
1943
|
+
if self.daemon:
|
|
1944
|
+
daemon_status = {
|
|
1945
|
+
"running": self.daemon.is_running,
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
# Try to get active client info from http_server
|
|
1949
|
+
clients_info = None
|
|
1950
|
+
try:
|
|
1951
|
+
from opencode_memory.http_server import get_active_clients
|
|
1952
|
+
|
|
1953
|
+
clients_info = get_active_clients()
|
|
1954
|
+
except ImportError:
|
|
1955
|
+
pass # Not running via http_server
|
|
1956
|
+
|
|
1957
|
+
# Get running background tasks
|
|
1958
|
+
running_tasks = _background_tasks.get_running_tasks()
|
|
1959
|
+
|
|
1960
|
+
result = {
|
|
1961
|
+
"embedding_queue": {
|
|
1962
|
+
"pending": pending_count,
|
|
1963
|
+
"max_concurrent": 4,
|
|
1964
|
+
"slots_available": semaphore_available,
|
|
1965
|
+
"status": "idle" if pending_count == 0 else "processing",
|
|
1966
|
+
},
|
|
1967
|
+
"storage": {
|
|
1968
|
+
"db_size_mb": round(db_size / (1024 * 1024), 2),
|
|
1969
|
+
"vectors_size_mb": round(vectors_size / (1024 * 1024), 2),
|
|
1970
|
+
"vector_versions": vector_versions,
|
|
1971
|
+
"db_path": str(self.config.db_path),
|
|
1972
|
+
},
|
|
1973
|
+
"memories": category_counts,
|
|
1974
|
+
"daemon": daemon_status,
|
|
1975
|
+
"links": self.sqlite.get_link_stats(),
|
|
1976
|
+
"cache": self.memory_cache.get_stats(),
|
|
1977
|
+
"background_tasks": running_tasks,
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if clients_info:
|
|
1981
|
+
result["clients"] = clients_info
|
|
1982
|
+
|
|
1983
|
+
return result
|
|
1984
|
+
|
|
1985
|
+
def _archive_memory(self, memory_id: int, reason: str | None) -> dict[str, Any]:
|
|
1986
|
+
"""Archive a memory record."""
|
|
1987
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
1988
|
+
if not memory:
|
|
1989
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
1990
|
+
|
|
1991
|
+
success = self.sqlite.archive_memory(memory_id, reason or "No reason provided")
|
|
1992
|
+
if success:
|
|
1993
|
+
# Invalidate cache
|
|
1994
|
+
self.memory_cache.invalidate(memory_id)
|
|
1995
|
+
return {
|
|
1996
|
+
"status": "archived",
|
|
1997
|
+
"memory_id": memory_id,
|
|
1998
|
+
"reason": reason,
|
|
1999
|
+
}
|
|
2000
|
+
return {"error": "Failed to archive memory"}
|
|
2001
|
+
|
|
2002
|
+
async def _delete_memory(
|
|
2003
|
+
self, memory_id: int, also_delete_vector: bool = True
|
|
2004
|
+
) -> dict[str, Any]:
|
|
2005
|
+
"""Permanently delete a memory."""
|
|
2006
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
2007
|
+
if not memory:
|
|
2008
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
2009
|
+
|
|
2010
|
+
# Delete from vector store first
|
|
2011
|
+
if also_delete_vector:
|
|
2012
|
+
try:
|
|
2013
|
+
self.vectors.delete_by_memory_id(memory_id)
|
|
2014
|
+
except Exception as e:
|
|
2015
|
+
logger.warning(f"Failed to delete vector for memory {memory_id}: {e}")
|
|
2016
|
+
|
|
2017
|
+
# Delete from SQLite (includes FTS and links)
|
|
2018
|
+
success = self.sqlite.delete_memory(memory_id)
|
|
2019
|
+
if success:
|
|
2020
|
+
# Invalidate cache
|
|
2021
|
+
self.memory_cache.invalidate(memory_id)
|
|
2022
|
+
return {
|
|
2023
|
+
"status": "deleted",
|
|
2024
|
+
"memory_id": memory_id,
|
|
2025
|
+
"vector_deleted": also_delete_vector,
|
|
2026
|
+
}
|
|
2027
|
+
return {"error": "Failed to delete memory"}
|
|
2028
|
+
|
|
2029
|
+
async def _edit_memory(
|
|
2030
|
+
self,
|
|
2031
|
+
memory_id: int,
|
|
2032
|
+
content: str | None = None,
|
|
2033
|
+
what: str | None = None,
|
|
2034
|
+
why: str | None = None,
|
|
2035
|
+
learned: str | None = None,
|
|
2036
|
+
recompute_embedding: bool = True,
|
|
2037
|
+
) -> dict[str, Any]:
|
|
2038
|
+
"""Edit a memory's content or metadata."""
|
|
2039
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
2040
|
+
if not memory:
|
|
2041
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
2042
|
+
|
|
2043
|
+
# Update in SQLite
|
|
2044
|
+
success = self.sqlite.update_memory(
|
|
2045
|
+
memory_id,
|
|
2046
|
+
content=content,
|
|
2047
|
+
what=what,
|
|
2048
|
+
why=why,
|
|
2049
|
+
learned=learned,
|
|
2050
|
+
)
|
|
2051
|
+
if not success:
|
|
2052
|
+
return {"error": "Failed to update memory"}
|
|
2053
|
+
|
|
2054
|
+
# Recompute embedding if content changed
|
|
2055
|
+
if content is not None and recompute_embedding:
|
|
2056
|
+
try:
|
|
2057
|
+
# Build new embedding content
|
|
2058
|
+
updated_memory = self.sqlite.get_memory_by_id(memory_id)
|
|
2059
|
+
if updated_memory:
|
|
2060
|
+
new_embedding = await self.embeddings.embed_async(
|
|
2061
|
+
updated_memory.embedding_content()
|
|
2062
|
+
)
|
|
2063
|
+
# Delete old and add new vector
|
|
2064
|
+
self.vectors.delete_by_memory_id(memory_id)
|
|
2065
|
+
self.vectors.add(
|
|
2066
|
+
f"mem_{memory_id}",
|
|
2067
|
+
memory_id,
|
|
2068
|
+
updated_memory.embedding_content(),
|
|
2069
|
+
new_embedding,
|
|
2070
|
+
)
|
|
2071
|
+
except Exception as e:
|
|
2072
|
+
logger.warning(f"Failed to recompute embedding for memory {memory_id}: {e}")
|
|
2073
|
+
return {
|
|
2074
|
+
"status": "partial",
|
|
2075
|
+
"memory_id": memory_id,
|
|
2076
|
+
"updated_fields": [
|
|
2077
|
+
f for f in ["content", "what", "why", "learned"] if locals().get(f)
|
|
2078
|
+
],
|
|
2079
|
+
"warning": f"Content updated but embedding failed: {e}",
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
# Invalidate cache so next fetch gets updated version
|
|
2083
|
+
self.memory_cache.invalidate(memory_id)
|
|
2084
|
+
|
|
2085
|
+
updated_fields = [
|
|
2086
|
+
name
|
|
2087
|
+
for name, value in [
|
|
2088
|
+
("content", content),
|
|
2089
|
+
("what", what),
|
|
2090
|
+
("why", why),
|
|
2091
|
+
("learned", learned),
|
|
2092
|
+
]
|
|
2093
|
+
if value is not None
|
|
2094
|
+
]
|
|
2095
|
+
|
|
2096
|
+
return {
|
|
2097
|
+
"status": "updated",
|
|
2098
|
+
"memory_id": memory_id,
|
|
2099
|
+
"updated_fields": updated_fields,
|
|
2100
|
+
"embedding_recomputed": content is not None and recompute_embedding,
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async def _bootstrap_memory(self, path: str | None) -> dict[str, Any]:
|
|
2104
|
+
"""Bootstrap project memory by scanning common project files."""
|
|
2105
|
+
import json
|
|
2106
|
+
import tomllib
|
|
2107
|
+
|
|
2108
|
+
project_path = Path(path) if path else Path.cwd()
|
|
2109
|
+
if not project_path.exists():
|
|
2110
|
+
return {"error": f"Path not found: {project_path}"}
|
|
2111
|
+
|
|
2112
|
+
project_name = _detect_current_project() or project_path.name
|
|
2113
|
+
facts_created = 0
|
|
2114
|
+
facts_skipped = 0
|
|
2115
|
+
scanned_files = []
|
|
2116
|
+
|
|
2117
|
+
existing_facts = self.sqlite.get_memories_by_category(
|
|
2118
|
+
MemoryCategory.FACT, limit=500, project=project_name
|
|
2119
|
+
)
|
|
2120
|
+
existing_content = {m.content.lower() for m in existing_facts}
|
|
2121
|
+
|
|
2122
|
+
async def store_fact(content: str, what: str) -> bool:
|
|
2123
|
+
nonlocal facts_created, facts_skipped
|
|
2124
|
+
if content.lower() in existing_content:
|
|
2125
|
+
facts_skipped += 1
|
|
2126
|
+
return False
|
|
2127
|
+
|
|
2128
|
+
await self._remember(
|
|
2129
|
+
content=content,
|
|
2130
|
+
category="fact",
|
|
2131
|
+
entities=[],
|
|
2132
|
+
what=what,
|
|
2133
|
+
why="Bootstrapped from project files",
|
|
2134
|
+
learned=None,
|
|
2135
|
+
project=project_name,
|
|
2136
|
+
)
|
|
2137
|
+
existing_content.add(content.lower())
|
|
2138
|
+
facts_created += 1
|
|
2139
|
+
return True
|
|
2140
|
+
|
|
2141
|
+
readme = project_path / "README.md"
|
|
2142
|
+
if readme.exists():
|
|
2143
|
+
scanned_files.append("README.md")
|
|
2144
|
+
text = readme.read_text(encoding="utf-8", errors="ignore")
|
|
2145
|
+
lines = text.split("\n")
|
|
2146
|
+
title = None
|
|
2147
|
+
description_lines = []
|
|
2148
|
+
|
|
2149
|
+
for line in lines[:20]:
|
|
2150
|
+
if line.startswith("# ") and not title:
|
|
2151
|
+
title = line[2:].strip()
|
|
2152
|
+
elif title and line.strip() and not line.startswith("#"):
|
|
2153
|
+
description_lines.append(line.strip())
|
|
2154
|
+
if len(description_lines) >= 3:
|
|
2155
|
+
break
|
|
2156
|
+
|
|
2157
|
+
if title:
|
|
2158
|
+
desc = " ".join(description_lines)[:200] if description_lines else ""
|
|
2159
|
+
content = f"Project '{title}': {desc}" if desc else f"Project: {title}"
|
|
2160
|
+
await store_fact(content, f"Project description for {project_name}")
|
|
2161
|
+
|
|
2162
|
+
pkg_json = project_path / "package.json"
|
|
2163
|
+
if pkg_json.exists():
|
|
2164
|
+
scanned_files.append("package.json")
|
|
2165
|
+
try:
|
|
2166
|
+
data = json.loads(pkg_json.read_text())
|
|
2167
|
+
name = data.get("name", "")
|
|
2168
|
+
desc = data.get("description", "")
|
|
2169
|
+
if name or desc:
|
|
2170
|
+
await store_fact(
|
|
2171
|
+
f"Node.js project '{name}': {desc}",
|
|
2172
|
+
"package.json description",
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
deps = list(data.get("dependencies", {}).keys())[:10]
|
|
2176
|
+
dev_deps = list(data.get("devDependencies", {}).keys())[:5]
|
|
2177
|
+
if deps:
|
|
2178
|
+
await store_fact(
|
|
2179
|
+
f"Main dependencies: {', '.join(deps)}",
|
|
2180
|
+
"Key npm dependencies",
|
|
2181
|
+
)
|
|
2182
|
+
if dev_deps:
|
|
2183
|
+
await store_fact(
|
|
2184
|
+
f"Dev dependencies: {', '.join(dev_deps)}",
|
|
2185
|
+
"Dev npm dependencies",
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
scripts = list(data.get("scripts", {}).keys())
|
|
2189
|
+
if scripts:
|
|
2190
|
+
await store_fact(
|
|
2191
|
+
f"Available npm scripts: {', '.join(scripts)}",
|
|
2192
|
+
"npm scripts",
|
|
2193
|
+
)
|
|
2194
|
+
except (json.JSONDecodeError, KeyError):
|
|
2195
|
+
pass
|
|
2196
|
+
|
|
2197
|
+
pyproject = project_path / "pyproject.toml"
|
|
2198
|
+
if pyproject.exists():
|
|
2199
|
+
scanned_files.append("pyproject.toml")
|
|
2200
|
+
try:
|
|
2201
|
+
data = tomllib.loads(pyproject.read_text())
|
|
2202
|
+
project_data = data.get("project", {})
|
|
2203
|
+
name = project_data.get("name", "")
|
|
2204
|
+
desc = project_data.get("description", "")
|
|
2205
|
+
if name or desc:
|
|
2206
|
+
await store_fact(
|
|
2207
|
+
f"Python project '{name}': {desc}",
|
|
2208
|
+
"pyproject.toml description",
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
deps = project_data.get("dependencies", [])[:10]
|
|
2212
|
+
if deps:
|
|
2213
|
+
dep_names = [
|
|
2214
|
+
d.split("[")[0].split(">")[0].split("<")[0].split("=")[0].strip()
|
|
2215
|
+
for d in deps
|
|
2216
|
+
]
|
|
2217
|
+
await store_fact(
|
|
2218
|
+
f"Python dependencies: {', '.join(dep_names)}",
|
|
2219
|
+
"Key Python dependencies",
|
|
2220
|
+
)
|
|
2221
|
+
except Exception:
|
|
2222
|
+
pass
|
|
2223
|
+
|
|
2224
|
+
gemfile = project_path / "Gemfile"
|
|
2225
|
+
if gemfile.exists():
|
|
2226
|
+
scanned_files.append("Gemfile")
|
|
2227
|
+
try:
|
|
2228
|
+
text = gemfile.read_text()
|
|
2229
|
+
gems = re.findall(r"gem ['\"]([^'\"]+)['\"]", text)[:10]
|
|
2230
|
+
if gems:
|
|
2231
|
+
await store_fact(
|
|
2232
|
+
f"Ruby gems: {', '.join(gems)}",
|
|
2233
|
+
"Key Ruby dependencies",
|
|
2234
|
+
)
|
|
2235
|
+
except Exception:
|
|
2236
|
+
pass
|
|
2237
|
+
|
|
2238
|
+
cargo = project_path / "Cargo.toml"
|
|
2239
|
+
if cargo.exists():
|
|
2240
|
+
scanned_files.append("Cargo.toml")
|
|
2241
|
+
try:
|
|
2242
|
+
data = tomllib.loads(cargo.read_text())
|
|
2243
|
+
pkg = data.get("package", {})
|
|
2244
|
+
name = pkg.get("name", "")
|
|
2245
|
+
desc = pkg.get("description", "")
|
|
2246
|
+
if name or desc:
|
|
2247
|
+
await store_fact(
|
|
2248
|
+
f"Rust project '{name}': {desc}",
|
|
2249
|
+
"Cargo.toml description",
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
deps = list(data.get("dependencies", {}).keys())[:10]
|
|
2253
|
+
if deps:
|
|
2254
|
+
await store_fact(
|
|
2255
|
+
f"Rust dependencies: {', '.join(deps)}",
|
|
2256
|
+
"Key Rust dependencies",
|
|
2257
|
+
)
|
|
2258
|
+
except Exception:
|
|
2259
|
+
pass
|
|
2260
|
+
|
|
2261
|
+
go_mod = project_path / "go.mod"
|
|
2262
|
+
if go_mod.exists():
|
|
2263
|
+
scanned_files.append("go.mod")
|
|
2264
|
+
try:
|
|
2265
|
+
text = go_mod.read_text()
|
|
2266
|
+
module_match = re.search(r"^module\s+(\S+)", text, re.MULTILINE)
|
|
2267
|
+
if module_match:
|
|
2268
|
+
await store_fact(
|
|
2269
|
+
f"Go module: {module_match.group(1)}",
|
|
2270
|
+
"Go module path",
|
|
2271
|
+
)
|
|
2272
|
+
|
|
2273
|
+
requires = re.findall(r"^\s+(\S+)\s+v", text, re.MULTILINE)[:10]
|
|
2274
|
+
if requires:
|
|
2275
|
+
await store_fact(
|
|
2276
|
+
f"Go dependencies: {', '.join(requires)}",
|
|
2277
|
+
"Key Go dependencies",
|
|
2278
|
+
)
|
|
2279
|
+
except Exception:
|
|
2280
|
+
pass
|
|
2281
|
+
|
|
2282
|
+
return {
|
|
2283
|
+
"status": "bootstrapped",
|
|
2284
|
+
"project": project_name,
|
|
2285
|
+
"path": str(project_path),
|
|
2286
|
+
"files_scanned": scanned_files,
|
|
2287
|
+
"facts_created": facts_created,
|
|
2288
|
+
"facts_skipped": facts_skipped,
|
|
2289
|
+
"note": "Run recall('project') to see stored facts"
|
|
2290
|
+
if facts_created
|
|
2291
|
+
else "No new facts to store",
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
def _get_linked_memories(
|
|
2295
|
+
self, memory_id: int, link_types: list[str] | None = None
|
|
2296
|
+
) -> dict[str, Any]:
|
|
2297
|
+
"""Get all memories linked to a specific memory.
|
|
2298
|
+
|
|
2299
|
+
Uses the memory cache for fast retrieval of prefetched linked memories.
|
|
2300
|
+
"""
|
|
2301
|
+
from opencode_memory.models import LinkType
|
|
2302
|
+
|
|
2303
|
+
# Try cache first for the source memory
|
|
2304
|
+
memory = self.memory_cache.get(memory_id)
|
|
2305
|
+
if not memory:
|
|
2306
|
+
memory = self.sqlite.get_memory_by_id(memory_id)
|
|
2307
|
+
if memory:
|
|
2308
|
+
self.memory_cache.put(memory)
|
|
2309
|
+
if not memory:
|
|
2310
|
+
return {"error": f"Memory {memory_id} not found"}
|
|
2311
|
+
|
|
2312
|
+
# Convert string types to LinkType enum
|
|
2313
|
+
type_filter = None
|
|
2314
|
+
if link_types:
|
|
2315
|
+
type_filter = [LinkType(t) for t in link_types]
|
|
2316
|
+
|
|
2317
|
+
# Get all links for this memory
|
|
2318
|
+
links = self.sqlite.get_all_links_for_memory(memory_id)
|
|
2319
|
+
|
|
2320
|
+
if type_filter:
|
|
2321
|
+
links = [lnk for lnk in links if lnk.link_type in type_filter]
|
|
2322
|
+
|
|
2323
|
+
if not links:
|
|
2324
|
+
return {
|
|
2325
|
+
"memory_id": memory_id,
|
|
2326
|
+
"memory_what": memory.what,
|
|
2327
|
+
"linked_memories": [],
|
|
2328
|
+
"note": "No links found for this memory",
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
# Collect unique linked memory IDs
|
|
2332
|
+
linked_ids: list[int] = []
|
|
2333
|
+
link_by_id: dict[int, Any] = {}
|
|
2334
|
+
for link in links:
|
|
2335
|
+
other_id = (
|
|
2336
|
+
link.target_memory_id
|
|
2337
|
+
if link.source_memory_id == memory_id
|
|
2338
|
+
else link.source_memory_id
|
|
2339
|
+
)
|
|
2340
|
+
if other_id not in link_by_id:
|
|
2341
|
+
linked_ids.append(other_id)
|
|
2342
|
+
link_by_id[other_id] = link
|
|
2343
|
+
|
|
2344
|
+
# Try cache first for linked memories
|
|
2345
|
+
cached = self.memory_cache.get_many(linked_ids)
|
|
2346
|
+
uncached_ids = [mid for mid in linked_ids if mid not in cached]
|
|
2347
|
+
|
|
2348
|
+
# Fetch uncached from database
|
|
2349
|
+
if uncached_ids:
|
|
2350
|
+
fetched = self.sqlite.get_memories_by_ids(uncached_ids)
|
|
2351
|
+
self.memory_cache.put_many(list(fetched.values()))
|
|
2352
|
+
cached.update(fetched)
|
|
2353
|
+
|
|
2354
|
+
# Build response
|
|
2355
|
+
linked_data = []
|
|
2356
|
+
for other_id in linked_ids:
|
|
2357
|
+
other_memory = cached.get(other_id)
|
|
2358
|
+
if not other_memory:
|
|
2359
|
+
continue
|
|
2360
|
+
|
|
2361
|
+
link = link_by_id[other_id]
|
|
2362
|
+
linked_data.append(
|
|
2363
|
+
{
|
|
2364
|
+
"id": other_memory.id,
|
|
2365
|
+
"content": other_memory.content,
|
|
2366
|
+
"category": other_memory.category.value,
|
|
2367
|
+
"what": other_memory.what,
|
|
2368
|
+
"why": other_memory.why,
|
|
2369
|
+
"learned": other_memory.learned,
|
|
2370
|
+
"age": _format_age(other_memory.created_at),
|
|
2371
|
+
"link_type": link.link_type.value,
|
|
2372
|
+
"link_strength": link.strength,
|
|
2373
|
+
"link_reason": link.reason,
|
|
2374
|
+
}
|
|
2375
|
+
)
|
|
2376
|
+
|
|
2377
|
+
return {
|
|
2378
|
+
"memory_id": memory_id,
|
|
2379
|
+
"memory_what": memory.what,
|
|
2380
|
+
"linked_memories": linked_data,
|
|
2381
|
+
"total_links": len(linked_data),
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
async def _export_memories(
|
|
2385
|
+
self,
|
|
2386
|
+
output_path: str | None,
|
|
2387
|
+
project: str | None,
|
|
2388
|
+
categories: list[str] | None,
|
|
2389
|
+
since_days: int | None,
|
|
2390
|
+
include_archived: bool,
|
|
2391
|
+
) -> dict[str, Any]:
|
|
2392
|
+
"""Queue memory export to run in background.
|
|
2393
|
+
|
|
2394
|
+
Returns immediately. Results stored as fact memory when complete.
|
|
2395
|
+
"""
|
|
2396
|
+
import json
|
|
2397
|
+
from datetime import timedelta
|
|
2398
|
+
|
|
2399
|
+
if project == "auto":
|
|
2400
|
+
project = _detect_current_project()
|
|
2401
|
+
|
|
2402
|
+
# Default output path
|
|
2403
|
+
if not output_path:
|
|
2404
|
+
output_path = str(Path.home() / "opencode-memory-export.json")
|
|
2405
|
+
else:
|
|
2406
|
+
output_path = str(Path(output_path).expanduser())
|
|
2407
|
+
|
|
2408
|
+
async def do_export():
|
|
2409
|
+
try:
|
|
2410
|
+
all_memories = self.sqlite.get_all_memories(
|
|
2411
|
+
project=project, include_archived=include_archived, limit=10000
|
|
2412
|
+
)
|
|
2413
|
+
|
|
2414
|
+
if categories:
|
|
2415
|
+
cat_set = {MemoryCategory(c) for c in categories}
|
|
2416
|
+
all_memories = [m for m in all_memories if m.category in cat_set]
|
|
2417
|
+
|
|
2418
|
+
if since_days:
|
|
2419
|
+
cutoff = datetime.now(UTC) - timedelta(days=since_days)
|
|
2420
|
+
|
|
2421
|
+
# Handle naive datetimes by assuming UTC
|
|
2422
|
+
def is_after_cutoff(mem_created_at: datetime) -> bool:
|
|
2423
|
+
if mem_created_at.tzinfo is None:
|
|
2424
|
+
mem_created_at = mem_created_at.replace(tzinfo=UTC)
|
|
2425
|
+
return mem_created_at > cutoff
|
|
2426
|
+
|
|
2427
|
+
all_memories = [m for m in all_memories if is_after_cutoff(m.created_at)]
|
|
2428
|
+
|
|
2429
|
+
all_entities = []
|
|
2430
|
+
all_links = []
|
|
2431
|
+
entity_ids_seen = set()
|
|
2432
|
+
|
|
2433
|
+
for memory in all_memories:
|
|
2434
|
+
if memory.id:
|
|
2435
|
+
links = self.sqlite.get_all_links_for_memory(memory.id)
|
|
2436
|
+
for link in links:
|
|
2437
|
+
all_links.append(
|
|
2438
|
+
{
|
|
2439
|
+
"source_memory_id": link.source_memory_id,
|
|
2440
|
+
"target_memory_id": link.target_memory_id,
|
|
2441
|
+
"link_type": link.link_type.value,
|
|
2442
|
+
"strength": link.strength,
|
|
2443
|
+
"reason": link.reason,
|
|
2444
|
+
}
|
|
2445
|
+
)
|
|
2446
|
+
|
|
2447
|
+
with self.sqlite._get_conn() as conn:
|
|
2448
|
+
cursor = conn.execute("SELECT * FROM entities")
|
|
2449
|
+
for row in cursor.fetchall():
|
|
2450
|
+
if row["id"] not in entity_ids_seen:
|
|
2451
|
+
entity_ids_seen.add(row["id"])
|
|
2452
|
+
all_entities.append(
|
|
2453
|
+
{
|
|
2454
|
+
"id": row["id"],
|
|
2455
|
+
"type": row["type"],
|
|
2456
|
+
"ref": row["ref"],
|
|
2457
|
+
"project": row["project"],
|
|
2458
|
+
"title": row["title"],
|
|
2459
|
+
"metadata": row["metadata"],
|
|
2460
|
+
}
|
|
2461
|
+
)
|
|
2462
|
+
|
|
2463
|
+
export_data = {
|
|
2464
|
+
"version": "1.0",
|
|
2465
|
+
"exported_at": datetime.now(UTC).isoformat(),
|
|
2466
|
+
"filters": {
|
|
2467
|
+
"project": project,
|
|
2468
|
+
"categories": categories,
|
|
2469
|
+
"since_days": since_days,
|
|
2470
|
+
"include_archived": include_archived,
|
|
2471
|
+
},
|
|
2472
|
+
"memories": [
|
|
2473
|
+
{
|
|
2474
|
+
"id": m.id,
|
|
2475
|
+
"category": m.category.value,
|
|
2476
|
+
"content": m.content,
|
|
2477
|
+
"what": m.what,
|
|
2478
|
+
"why": m.why,
|
|
2479
|
+
"learned": m.learned,
|
|
2480
|
+
"project": m.project,
|
|
2481
|
+
"source_file": m.source_file,
|
|
2482
|
+
"created_at": m.created_at.isoformat(),
|
|
2483
|
+
"resolved_at": m.resolved_at.isoformat() if m.resolved_at else None,
|
|
2484
|
+
"entities": m.entities,
|
|
2485
|
+
}
|
|
2486
|
+
for m in all_memories
|
|
2487
|
+
],
|
|
2488
|
+
"entities": all_entities,
|
|
2489
|
+
"links": all_links,
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
Path(output_path).write_text(json.dumps(export_data, indent=2))
|
|
2493
|
+
|
|
2494
|
+
# Store completion as fact
|
|
2495
|
+
result_memory = Memory(
|
|
2496
|
+
category=MemoryCategory.FACT,
|
|
2497
|
+
content=f"Export completed: {len(all_memories)} memories, {len(all_entities)} entities, {len(all_links)} links → {output_path}",
|
|
2498
|
+
what="Memory export completed",
|
|
2499
|
+
expires_at=datetime.now(UTC) + timedelta(days=1),
|
|
2500
|
+
)
|
|
2501
|
+
self.sqlite.insert_memory(result_memory)
|
|
2502
|
+
logger.info(f"Export completed: {output_path}")
|
|
2503
|
+
|
|
2504
|
+
except Exception as e:
|
|
2505
|
+
logger.exception(f"Export failed: {e}")
|
|
2506
|
+
|
|
2507
|
+
task = asyncio.create_task(do_export())
|
|
2508
|
+
task_id = _background_tasks.register("export_memories", task)
|
|
2509
|
+
|
|
2510
|
+
return {
|
|
2511
|
+
"status": "queued",
|
|
2512
|
+
"task_id": task_id,
|
|
2513
|
+
"path": output_path,
|
|
2514
|
+
"message": "Export running in background",
|
|
2515
|
+
"hint": "Check file or recall('export completed') when done",
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
async def _import_memories(
|
|
2519
|
+
self,
|
|
2520
|
+
input_path: str,
|
|
2521
|
+
dry_run: bool,
|
|
2522
|
+
skip_duplicates: bool,
|
|
2523
|
+
) -> dict[str, Any]:
|
|
2524
|
+
"""Queue memory import to run in background.
|
|
2525
|
+
|
|
2526
|
+
Dry run returns immediately with counts. Full import runs async.
|
|
2527
|
+
"""
|
|
2528
|
+
import json
|
|
2529
|
+
|
|
2530
|
+
path = Path(input_path).expanduser()
|
|
2531
|
+
if not path.exists():
|
|
2532
|
+
return {"error": f"File not found: {input_path}"}
|
|
2533
|
+
|
|
2534
|
+
try:
|
|
2535
|
+
data = json.loads(path.read_text())
|
|
2536
|
+
except json.JSONDecodeError as e:
|
|
2537
|
+
return {"error": f"Invalid JSON: {e}"}
|
|
2538
|
+
|
|
2539
|
+
version = data.get("version", "unknown")
|
|
2540
|
+
memories_data = data.get("memories", [])
|
|
2541
|
+
entities_data = data.get("entities", [])
|
|
2542
|
+
links_data = data.get("links", [])
|
|
2543
|
+
|
|
2544
|
+
# Dry run: quick count without loading all existing memories
|
|
2545
|
+
if dry_run:
|
|
2546
|
+
return {
|
|
2547
|
+
"status": "dry_run",
|
|
2548
|
+
"version": version,
|
|
2549
|
+
"memories_to_import": len(memories_data),
|
|
2550
|
+
"entities_to_import": len(entities_data),
|
|
2551
|
+
"links_to_import": len(links_data),
|
|
2552
|
+
"note": "Run without dry_run=true to import",
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
# Queue the actual import
|
|
2556
|
+
async def do_import():
|
|
2557
|
+
try:
|
|
2558
|
+
imported = 0
|
|
2559
|
+
skipped = 0
|
|
2560
|
+
errors = []
|
|
2561
|
+
|
|
2562
|
+
existing_hashes = set()
|
|
2563
|
+
if skip_duplicates:
|
|
2564
|
+
existing = self.sqlite.get_all_memories(include_archived=False, limit=10000)
|
|
2565
|
+
existing_hashes = {hash(m.content.strip().lower()) for m in existing}
|
|
2566
|
+
|
|
2567
|
+
memory_id_map: dict[int, int] = {}
|
|
2568
|
+
entity_id_map: dict[int, int] = {}
|
|
2569
|
+
|
|
2570
|
+
for entity_data in entities_data:
|
|
2571
|
+
entity = Entity(
|
|
2572
|
+
type=EntityType(entity_data["type"]),
|
|
2573
|
+
ref=entity_data["ref"],
|
|
2574
|
+
project=entity_data.get("project"),
|
|
2575
|
+
title=entity_data.get("title"),
|
|
2576
|
+
)
|
|
2577
|
+
new_id = self.sqlite.upsert_entity(entity)
|
|
2578
|
+
if entity_data.get("id"):
|
|
2579
|
+
entity_id_map[entity_data["id"]] = new_id
|
|
2580
|
+
|
|
2581
|
+
memories_to_import = []
|
|
2582
|
+
for mem_data in memories_data:
|
|
2583
|
+
content = mem_data.get("content", "")
|
|
2584
|
+
content_hash = hash(content.strip().lower())
|
|
2585
|
+
|
|
2586
|
+
if skip_duplicates and content_hash in existing_hashes:
|
|
2587
|
+
skipped += 1
|
|
2588
|
+
continue
|
|
2589
|
+
|
|
2590
|
+
memories_to_import.append((mem_data, content, content_hash))
|
|
2591
|
+
|
|
2592
|
+
batch_size = 50
|
|
2593
|
+
for batch_start in range(0, len(memories_to_import), batch_size):
|
|
2594
|
+
batch = memories_to_import[batch_start : batch_start + batch_size]
|
|
2595
|
+
|
|
2596
|
+
batch_memories = []
|
|
2597
|
+
for mem_data, content, content_hash in batch:
|
|
2598
|
+
try:
|
|
2599
|
+
memory = Memory(
|
|
2600
|
+
category=MemoryCategory(mem_data["category"]),
|
|
2601
|
+
content=content,
|
|
2602
|
+
what=mem_data.get("what"),
|
|
2603
|
+
why=mem_data.get("why"),
|
|
2604
|
+
learned=mem_data.get("learned"),
|
|
2605
|
+
project=mem_data.get("project"),
|
|
2606
|
+
source_file=mem_data.get("source_file"),
|
|
2607
|
+
entities=mem_data.get("entities", []),
|
|
2608
|
+
)
|
|
2609
|
+
|
|
2610
|
+
entity_ids = []
|
|
2611
|
+
for ref in memory.entities:
|
|
2612
|
+
entity = Entity.from_ref(ref)
|
|
2613
|
+
if entity:
|
|
2614
|
+
entity_id = self.sqlite.upsert_entity(entity)
|
|
2615
|
+
entity_ids.append(entity_id)
|
|
2616
|
+
|
|
2617
|
+
new_id = self.sqlite.insert_memory(memory, entity_ids)
|
|
2618
|
+
memory.id = new_id
|
|
2619
|
+
self.memory_cache.put(memory)
|
|
2620
|
+
if mem_data.get("id"):
|
|
2621
|
+
memory_id_map[mem_data["id"]] = new_id
|
|
2622
|
+
|
|
2623
|
+
batch_memories.append((new_id, memory.embedding_content()))
|
|
2624
|
+
imported += 1
|
|
2625
|
+
existing_hashes.add(content_hash)
|
|
2626
|
+
except Exception as e:
|
|
2627
|
+
errors.append(str(e))
|
|
2628
|
+
|
|
2629
|
+
if batch_memories:
|
|
2630
|
+
try:
|
|
2631
|
+
texts = [text for _, text in batch_memories]
|
|
2632
|
+
embeddings = await self.embeddings.embed_batch_async(texts)
|
|
2633
|
+
for (mem_id, text), embedding in zip(batch_memories, embeddings):
|
|
2634
|
+
self.vectors.add(f"mem_{mem_id}", mem_id, text, embedding)
|
|
2635
|
+
except Exception as e:
|
|
2636
|
+
logger.warning(f"Batch embedding failed: {e}")
|
|
2637
|
+
for mem_id, text in batch_memories:
|
|
2638
|
+
try:
|
|
2639
|
+
embedding = await self.embeddings.embed_async(text)
|
|
2640
|
+
self.vectors.add(f"mem_{mem_id}", mem_id, text, embedding)
|
|
2641
|
+
except Exception:
|
|
2642
|
+
pass
|
|
2643
|
+
|
|
2644
|
+
links_imported = 0
|
|
2645
|
+
for link_data in links_data:
|
|
2646
|
+
old_source = link_data.get("source_memory_id")
|
|
2647
|
+
old_target = link_data.get("target_memory_id")
|
|
2648
|
+
new_source = memory_id_map.get(old_source)
|
|
2649
|
+
new_target = memory_id_map.get(old_target)
|
|
2650
|
+
|
|
2651
|
+
if new_source and new_target:
|
|
2652
|
+
link = MemoryLink(
|
|
2653
|
+
source_memory_id=new_source,
|
|
2654
|
+
target_memory_id=new_target,
|
|
2655
|
+
link_type=LinkType(link_data["link_type"]),
|
|
2656
|
+
strength=link_data.get("strength", 0.5),
|
|
2657
|
+
reason=link_data.get("reason"),
|
|
2658
|
+
)
|
|
2659
|
+
if self.sqlite.insert_link(link):
|
|
2660
|
+
links_imported += 1
|
|
2661
|
+
|
|
2662
|
+
# Store completion as fact
|
|
2663
|
+
result_memory = Memory(
|
|
2664
|
+
category=MemoryCategory.FACT,
|
|
2665
|
+
content=f"Import completed: {imported} memories, {skipped} skipped, {links_imported} links from {input_path}",
|
|
2666
|
+
what="Memory import completed",
|
|
2667
|
+
expires_at=datetime.now(UTC) + timedelta(days=1),
|
|
2668
|
+
)
|
|
2669
|
+
self.sqlite.insert_memory(result_memory)
|
|
2670
|
+
logger.info(f"Import completed: {imported} memories from {input_path}")
|
|
2671
|
+
|
|
2672
|
+
except Exception as e:
|
|
2673
|
+
logger.exception(f"Import failed: {e}")
|
|
2674
|
+
|
|
2675
|
+
task = asyncio.create_task(do_import())
|
|
2676
|
+
task_id = _background_tasks.register("import_memories", task)
|
|
2677
|
+
|
|
2678
|
+
return {
|
|
2679
|
+
"status": "queued",
|
|
2680
|
+
"task_id": task_id,
|
|
2681
|
+
"path": str(path),
|
|
2682
|
+
"memories_count": len(memories_data),
|
|
2683
|
+
"message": "Import running in background",
|
|
2684
|
+
"hint": "Use recall('import completed') to check status",
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
def _bulk_archive(
|
|
2688
|
+
self,
|
|
2689
|
+
memory_ids: list[int] | None,
|
|
2690
|
+
category: str | None,
|
|
2691
|
+
older_than_days: int | None,
|
|
2692
|
+
reason: str,
|
|
2693
|
+
) -> dict[str, Any]:
|
|
2694
|
+
"""Archive multiple memories at once."""
|
|
2695
|
+
from datetime import timedelta
|
|
2696
|
+
|
|
2697
|
+
if not reason:
|
|
2698
|
+
return {"error": "Reason is required for bulk archive"}
|
|
2699
|
+
|
|
2700
|
+
ids_to_archive: list[int] = []
|
|
2701
|
+
|
|
2702
|
+
# Collect IDs from explicit list
|
|
2703
|
+
if memory_ids:
|
|
2704
|
+
ids_to_archive.extend(memory_ids)
|
|
2705
|
+
|
|
2706
|
+
# Collect IDs from category + age filter
|
|
2707
|
+
if category or older_than_days:
|
|
2708
|
+
with self.sqlite._get_conn() as conn:
|
|
2709
|
+
conditions = ["resolved_at IS NOT NULL"] # Only archive resolved
|
|
2710
|
+
params: list[Any] = []
|
|
2711
|
+
|
|
2712
|
+
if category:
|
|
2713
|
+
conditions.append("category = ?")
|
|
2714
|
+
params.append(category)
|
|
2715
|
+
|
|
2716
|
+
if older_than_days:
|
|
2717
|
+
cutoff = (datetime.now(UTC) - timedelta(days=older_than_days)).isoformat()
|
|
2718
|
+
conditions.append("created_at < ?")
|
|
2719
|
+
params.append(cutoff)
|
|
2720
|
+
|
|
2721
|
+
query = f"SELECT id FROM memories WHERE {' AND '.join(conditions)}"
|
|
2722
|
+
cursor = conn.execute(query, params)
|
|
2723
|
+
ids_to_archive.extend(row["id"] for row in cursor.fetchall())
|
|
2724
|
+
|
|
2725
|
+
# Deduplicate
|
|
2726
|
+
ids_to_archive = list(set(ids_to_archive))
|
|
2727
|
+
|
|
2728
|
+
if not ids_to_archive:
|
|
2729
|
+
return {"status": "no_matches", "archived_count": 0}
|
|
2730
|
+
|
|
2731
|
+
# Archive each memory
|
|
2732
|
+
archived = 0
|
|
2733
|
+
errors = []
|
|
2734
|
+
for memory_id in ids_to_archive:
|
|
2735
|
+
try:
|
|
2736
|
+
if self.sqlite.archive_memory(memory_id, reason):
|
|
2737
|
+
archived += 1
|
|
2738
|
+
except Exception as e:
|
|
2739
|
+
errors.append(f"Failed to archive {memory_id}: {e}")
|
|
2740
|
+
|
|
2741
|
+
return {
|
|
2742
|
+
"status": "archived",
|
|
2743
|
+
"archived_count": archived,
|
|
2744
|
+
"requested_count": len(ids_to_archive),
|
|
2745
|
+
"errors": errors[:5] if errors else None,
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async def _cache_cleanup_loop(self) -> None:
|
|
2749
|
+
"""Periodically clean expired cache entries."""
|
|
2750
|
+
while True:
|
|
2751
|
+
await asyncio.sleep(CACHE_CLEANUP_INTERVAL_SECONDS)
|
|
2752
|
+
try:
|
|
2753
|
+
removed = self.memory_cache.cleanup_expired()
|
|
2754
|
+
if removed > 0:
|
|
2755
|
+
logger.debug(f"Cache cleanup: removed {removed} expired entries")
|
|
2756
|
+
except Exception as e:
|
|
2757
|
+
logger.warning(f"Cache cleanup error: {e}")
|
|
2758
|
+
|
|
2759
|
+
async def run(self) -> None:
|
|
2760
|
+
"""Run the MCP server."""
|
|
2761
|
+
if self.daemon:
|
|
2762
|
+
await self.daemon.start()
|
|
2763
|
+
|
|
2764
|
+
# Start cache cleanup background task
|
|
2765
|
+
self._cache_cleanup_task = asyncio.create_task(self._cache_cleanup_loop())
|
|
2766
|
+
|
|
2767
|
+
try:
|
|
2768
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
2769
|
+
await self.server.run(
|
|
2770
|
+
read_stream,
|
|
2771
|
+
write_stream,
|
|
2772
|
+
self.server.create_initialization_options(),
|
|
2773
|
+
)
|
|
2774
|
+
finally:
|
|
2775
|
+
# Stop cache cleanup
|
|
2776
|
+
if self._cache_cleanup_task:
|
|
2777
|
+
self._cache_cleanup_task.cancel()
|
|
2778
|
+
try:
|
|
2779
|
+
await self._cache_cleanup_task
|
|
2780
|
+
except asyncio.CancelledError:
|
|
2781
|
+
pass
|
|
2782
|
+
|
|
2783
|
+
await self.enricher.close()
|
|
2784
|
+
if self.daemon:
|
|
2785
|
+
await self.daemon.stop()
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def main(enable_daemon: bool = True) -> None:
|
|
2789
|
+
"""Main entry point."""
|
|
2790
|
+
server = MemoryServer(enable_daemon=enable_daemon)
|
|
2791
|
+
asyncio.run(server.run())
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
if __name__ == "__main__":
|
|
2795
|
+
main()
|