opencode-semantic-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()