openchronicle-mcp 3.0.0.dev0__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.
Files changed (102) hide show
  1. openchronicle/__init__.py +1 -0
  2. openchronicle/core/__init__.py +0 -0
  3. openchronicle/core/application/__init__.py +0 -0
  4. openchronicle/core/application/config/__init__.py +1 -0
  5. openchronicle/core/application/config/env_helpers.py +117 -0
  6. openchronicle/core/application/config/paths.py +79 -0
  7. openchronicle/core/application/config/settings.py +56 -0
  8. openchronicle/core/application/models/__init__.py +1 -0
  9. openchronicle/core/application/models/diagnostics_report.py +32 -0
  10. openchronicle/core/application/services/__init__.py +1 -0
  11. openchronicle/core/application/services/embedding_service.py +325 -0
  12. openchronicle/core/application/services/git_onboard.py +473 -0
  13. openchronicle/core/application/services/maintenance_loop.py +266 -0
  14. openchronicle/core/application/use_cases/__init__.py +0 -0
  15. openchronicle/core/application/use_cases/add_memory.py +30 -0
  16. openchronicle/core/application/use_cases/create_project.py +12 -0
  17. openchronicle/core/application/use_cases/delete_memory.py +45 -0
  18. openchronicle/core/application/use_cases/delete_project.py +45 -0
  19. openchronicle/core/application/use_cases/diagnose_runtime.py +77 -0
  20. openchronicle/core/application/use_cases/export_memory.py +73 -0
  21. openchronicle/core/application/use_cases/import_memory.py +95 -0
  22. openchronicle/core/application/use_cases/init_config.py +179 -0
  23. openchronicle/core/application/use_cases/init_runtime.py +60 -0
  24. openchronicle/core/application/use_cases/list_memory.py +10 -0
  25. openchronicle/core/application/use_cases/list_projects.py +8 -0
  26. openchronicle/core/application/use_cases/pin_memory.py +12 -0
  27. openchronicle/core/application/use_cases/search_memory.py +39 -0
  28. openchronicle/core/application/use_cases/show_memory.py +13 -0
  29. openchronicle/core/application/use_cases/update_memory.py +36 -0
  30. openchronicle/core/application/use_cases/update_project.py +23 -0
  31. openchronicle/core/domain/__init__.py +0 -0
  32. openchronicle/core/domain/errors/__init__.py +37 -0
  33. openchronicle/core/domain/errors/error_codes.py +49 -0
  34. openchronicle/core/domain/exceptions.py +61 -0
  35. openchronicle/core/domain/models/__init__.py +0 -0
  36. openchronicle/core/domain/models/git_commit.py +29 -0
  37. openchronicle/core/domain/models/memory_item.py +19 -0
  38. openchronicle/core/domain/models/project.py +16 -0
  39. openchronicle/core/domain/ports/__init__.py +0 -0
  40. openchronicle/core/domain/ports/embedding_port.py +25 -0
  41. openchronicle/core/domain/ports/memory_store_port.py +79 -0
  42. openchronicle/core/domain/ports/storage_port.py +62 -0
  43. openchronicle/core/domain/time_utils.py +10 -0
  44. openchronicle/core/infrastructure/__init__.py +0 -0
  45. openchronicle/core/infrastructure/config/__init__.py +1 -0
  46. openchronicle/core/infrastructure/config/config_loader.py +58 -0
  47. openchronicle/core/infrastructure/embedding/__init__.py +0 -0
  48. openchronicle/core/infrastructure/embedding/ollama_adapter.py +84 -0
  49. openchronicle/core/infrastructure/embedding/openai_adapter.py +94 -0
  50. openchronicle/core/infrastructure/embedding/stub_adapter.py +53 -0
  51. openchronicle/core/infrastructure/maintenance/__init__.py +0 -0
  52. openchronicle/core/infrastructure/maintenance/jobs.py +148 -0
  53. openchronicle/core/infrastructure/persistence/__init__.py +0 -0
  54. openchronicle/core/infrastructure/persistence/backup.py +99 -0
  55. openchronicle/core/infrastructure/persistence/migrations/001_initial.sql +49 -0
  56. openchronicle/core/infrastructure/persistence/migrations/__init__.py +0 -0
  57. openchronicle/core/infrastructure/persistence/migrator.py +137 -0
  58. openchronicle/core/infrastructure/persistence/row_mappers.py +38 -0
  59. openchronicle/core/infrastructure/persistence/sqlite_store.py +578 -0
  60. openchronicle/core/infrastructure/wiring/__init__.py +0 -0
  61. openchronicle/core/infrastructure/wiring/container.py +176 -0
  62. openchronicle/interfaces/__init__.py +0 -0
  63. openchronicle/interfaces/api/__init__.py +3 -0
  64. openchronicle/interfaces/api/app.py +158 -0
  65. openchronicle/interfaces/api/config.py +54 -0
  66. openchronicle/interfaces/api/deps.py +19 -0
  67. openchronicle/interfaces/api/middleware/__init__.py +47 -0
  68. openchronicle/interfaces/api/middleware/auth.py +64 -0
  69. openchronicle/interfaces/api/middleware/rate_limit.py +71 -0
  70. openchronicle/interfaces/api/routes/__init__.py +3 -0
  71. openchronicle/interfaces/api/routes/memory.py +234 -0
  72. openchronicle/interfaces/api/routes/project.py +119 -0
  73. openchronicle/interfaces/api/routes/system.py +39 -0
  74. openchronicle/interfaces/cli/__init__.py +0 -0
  75. openchronicle/interfaces/cli/commands/__init__.py +50 -0
  76. openchronicle/interfaces/cli/commands/_helpers.py +48 -0
  77. openchronicle/interfaces/cli/commands/db.py +159 -0
  78. openchronicle/interfaces/cli/commands/maintenance.py +86 -0
  79. openchronicle/interfaces/cli/commands/memory.py +308 -0
  80. openchronicle/interfaces/cli/commands/onboard.py +100 -0
  81. openchronicle/interfaces/cli/commands/project.py +180 -0
  82. openchronicle/interfaces/cli/commands/system.py +217 -0
  83. openchronicle/interfaces/cli/main.py +225 -0
  84. openchronicle/interfaces/logging_setup.py +109 -0
  85. openchronicle/interfaces/mcp/__init__.py +3 -0
  86. openchronicle/interfaces/mcp/__main__.py +32 -0
  87. openchronicle/interfaces/mcp/config.py +105 -0
  88. openchronicle/interfaces/mcp/server.py +75 -0
  89. openchronicle/interfaces/mcp/tools/__init__.py +0 -0
  90. openchronicle/interfaces/mcp/tools/context.py +60 -0
  91. openchronicle/interfaces/mcp/tools/memory.py +329 -0
  92. openchronicle/interfaces/mcp/tools/onboard.py +155 -0
  93. openchronicle/interfaces/mcp/tools/project.py +144 -0
  94. openchronicle/interfaces/mcp/tools/system.py +38 -0
  95. openchronicle/interfaces/serializers.py +30 -0
  96. openchronicle/py.typed +0 -0
  97. openchronicle_mcp-3.0.0.dev0.dist-info/METADATA +187 -0
  98. openchronicle_mcp-3.0.0.dev0.dist-info/RECORD +102 -0
  99. openchronicle_mcp-3.0.0.dev0.dist-info/WHEEL +5 -0
  100. openchronicle_mcp-3.0.0.dev0.dist-info/entry_points.txt +2 -0
  101. openchronicle_mcp-3.0.0.dev0.dist-info/licenses/LICENSE +661 -0
  102. openchronicle_mcp-3.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __all__: list[str] = []
File without changes
File without changes
@@ -0,0 +1 @@
1
+ """Application configuration loading."""
@@ -0,0 +1,117 @@
1
+ """Consolidated environment variable and config value parsing helpers.
2
+
3
+ These helpers handle three-layer precedence: dataclass defaults -> JSON file
4
+ values -> env var overrides. They work with both string values (from env vars)
5
+ and native JSON types (bool, int, float) from config files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+
13
+ def parse_bool(value: object, *, default: bool) -> bool:
14
+ """Parse a boolean from string, native bool, or None.
15
+
16
+ Truthy strings: "1", "true", "yes", "on" (case-insensitive).
17
+ Falsy strings: "0", "false", "no", "off" (case-insensitive).
18
+ Native bools pass through. None returns default.
19
+ """
20
+ if value is None:
21
+ return default
22
+ if isinstance(value, bool):
23
+ return value
24
+ if isinstance(value, str):
25
+ return value.strip().lower() in {"1", "true", "yes", "on"}
26
+ return default
27
+
28
+
29
+ def parse_int(value: object, *, default: int) -> int:
30
+ """Parse an integer from string, native int, or None.
31
+
32
+ Native ints pass through. Strings are stripped and converted.
33
+ Invalid values return default. None returns default.
34
+ """
35
+ if value is None:
36
+ return default
37
+ if isinstance(value, bool):
38
+ # bool is a subclass of int in Python — reject it
39
+ return default
40
+ if isinstance(value, int):
41
+ return value
42
+ if isinstance(value, str):
43
+ raw = value.strip()
44
+ try:
45
+ return int(raw)
46
+ except ValueError:
47
+ return default
48
+ return default
49
+
50
+
51
+ def parse_float(value: object, *, default: float) -> float:
52
+ """Parse a float from string, native float/int, or None.
53
+
54
+ Native floats/ints pass through. Strings are stripped and converted.
55
+ Invalid values return default. None returns default.
56
+ """
57
+ if value is None:
58
+ return default
59
+ if isinstance(value, bool):
60
+ return default
61
+ if isinstance(value, int | float):
62
+ return float(value)
63
+ if isinstance(value, str):
64
+ raw = value.strip()
65
+ try:
66
+ return float(raw)
67
+ except ValueError:
68
+ return default
69
+ return default
70
+
71
+
72
+ def parse_str(value: object, *, default: str) -> str:
73
+ """Parse a string value, returning default for None or empty."""
74
+ if value is None:
75
+ return default
76
+ if isinstance(value, str):
77
+ return value if value else default
78
+ return str(value)
79
+
80
+
81
+ def parse_str_list(value: object, *, default: list[str]) -> list[str]:
82
+ """Parse a list of strings from a JSON array or CSV string.
83
+
84
+ - list[str]: pass through (filtering empties)
85
+ - str: split on commas, strip, filter empties
86
+ - None: return default
87
+ """
88
+ if value is None:
89
+ return list(default)
90
+ if isinstance(value, list):
91
+ return [str(item).strip() for item in value if str(item).strip()]
92
+ if isinstance(value, str):
93
+ return [item.strip() for item in value.split(",") if item.strip()]
94
+ return list(default)
95
+
96
+
97
+ def env_override(env_name: str, file_value: object) -> object:
98
+ """Return env var if set, otherwise file_value.
99
+
100
+ This implements the precedence: env var > JSON file > (caller's default).
101
+ Only returns the env var when it is explicitly set in the environment.
102
+ """
103
+ env_val = os.getenv(env_name)
104
+ if env_val is not None:
105
+ return env_val
106
+ return file_value
107
+
108
+
109
+ def parse_csv_tags(value: str | None) -> list[str] | None:
110
+ """Parse a comma-separated string into a list of stripped, non-empty tags.
111
+
112
+ Returns None if the input is None or empty, which signals "no filter"
113
+ in search contexts.
114
+ """
115
+ if not value:
116
+ return None
117
+ return [t.strip() for t in value.split(",") if t.strip()]
@@ -0,0 +1,79 @@
1
+ """Canonical runtime path resolution for the entire project.
2
+
3
+ All data-directory paths flow through ``RuntimePaths.resolve()``.
4
+ Four-layer precedence: constructor param > per-path env var >
5
+ ``OC_DATA_DIR``-derived > hardcoded default.
6
+
7
+ v3 manages three paths: the SQLite file, the config dir, and an
8
+ output dir. v2's plugin/assets/discord paths are gone with their
9
+ subsystems.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ # ── Default constants ────────────────────────────────────────────────
19
+
20
+ DEFAULT_DB_PATH = "data/openchronicle.db"
21
+ DEFAULT_CONFIG_DIR = "config"
22
+ DEFAULT_OUTPUT_DIR = "output"
23
+
24
+
25
+ def _resolve(
26
+ explicit: str | Path | None,
27
+ env_var: str,
28
+ data_dir: str | None,
29
+ data_dir_suffix: str,
30
+ fallback: str,
31
+ ) -> Path:
32
+ """Four-layer path resolution.
33
+
34
+ 1. Constructor param (``explicit``) — wins unconditionally.
35
+ 2. Per-path env var — checked next.
36
+ 3. ``OC_DATA_DIR + suffix`` — if ``OC_DATA_DIR`` is set.
37
+ 4. Hardcoded fallback — last resort.
38
+ """
39
+ if explicit is not None:
40
+ return Path(explicit)
41
+ env_val = os.environ.get(env_var)
42
+ if env_val is not None:
43
+ return Path(env_val)
44
+ if data_dir is not None:
45
+ return Path(data_dir) / data_dir_suffix
46
+ return Path(fallback)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RuntimePaths:
51
+ """Resolved runtime paths for v3 data artifacts."""
52
+
53
+ db_path: Path
54
+ config_dir: Path
55
+ output_dir: Path
56
+
57
+ @classmethod
58
+ def resolve(
59
+ cls,
60
+ *,
61
+ db_path: str | Path | None = None,
62
+ config_dir: str | Path | None = None,
63
+ output_dir: str | Path | None = None,
64
+ # Accepted but ignored for backwards-compatible callers (e.g.
65
+ # CLI flags / kwargs that haven't been pruned in lock step).
66
+ plugin_dir: str | Path | None = None, # noqa: ARG003 - accepted for kwarg stability
67
+ ) -> RuntimePaths:
68
+ """Build ``RuntimePaths`` with four-layer precedence.
69
+
70
+ Constructor params > per-path env vars > ``OC_DATA_DIR``-derived
71
+ > defaults.
72
+ """
73
+ data_dir = os.environ.get("OC_DATA_DIR")
74
+
75
+ return cls(
76
+ db_path=_resolve(db_path, "OC_DB_PATH", data_dir, "openchronicle.db", DEFAULT_DB_PATH),
77
+ config_dir=_resolve(config_dir, "OC_CONFIG_DIR", data_dir, "config", DEFAULT_CONFIG_DIR),
78
+ output_dir=_resolve(output_dir, "OC_OUTPUT_DIR", data_dir, "output", DEFAULT_OUTPUT_DIR),
79
+ )
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from openchronicle.core.application.config.env_helpers import (
7
+ env_override,
8
+ parse_int,
9
+ parse_str,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EmbeddingSettings:
15
+ """Embedding provider configuration."""
16
+
17
+ provider: str = "none"
18
+ model: str = ""
19
+ dimensions: int | None = None
20
+ api_key: str = ""
21
+ timeout: float = 30.0
22
+
23
+ def __post_init__(self) -> None:
24
+ valid = {"none", "stub", "openai", "ollama"}
25
+ if self.provider not in valid:
26
+ raise ValueError(f"embedding provider must be one of {valid}, got {self.provider!r}")
27
+ if self.dimensions is not None and self.dimensions < 1:
28
+ raise ValueError(f"embedding dimensions must be >= 1, got {self.dimensions}")
29
+ if self.timeout <= 0:
30
+ raise ValueError(f"embedding timeout must be > 0, got {self.timeout}")
31
+
32
+
33
+ def load_embedding_settings(
34
+ file_config: dict[str, Any] | None = None,
35
+ ) -> EmbeddingSettings:
36
+ fc = file_config or {}
37
+ dims_raw = env_override("OC_EMBEDDING_DIMENSIONS", fc.get("dimensions"))
38
+ dims = parse_int(dims_raw, default=0) if dims_raw is not None else 0
39
+ timeout_raw = env_override("OC_EMBEDDING_TIMEOUT", fc.get("timeout"))
40
+ timeout = float(str(timeout_raw)) if timeout_raw is not None else 30.0
41
+ return EmbeddingSettings(
42
+ provider=parse_str(
43
+ env_override("OC_EMBEDDING_PROVIDER", fc.get("provider")),
44
+ default="none",
45
+ ).lower(),
46
+ model=parse_str(
47
+ env_override("OC_EMBEDDING_MODEL", fc.get("model")),
48
+ default="",
49
+ ),
50
+ dimensions=dims if dims != 0 else None,
51
+ api_key=parse_str(
52
+ env_override("OC_EMBEDDING_API_KEY", fc.get("api_key")),
53
+ default="",
54
+ ),
55
+ timeout=timeout,
56
+ )
@@ -0,0 +1 @@
1
+ """Application models."""
@@ -0,0 +1,32 @@
1
+ """Diagnostics report model.
2
+
3
+ Returned by ``diagnose_runtime.execute()`` and surfaced by the v3
4
+ health endpoints (``/health``, ``/api/v1/health``, MCP ``health`` tool).
5
+ v2's plugin_dir / model config discovery / OC_LLM_* env summary fields
6
+ are gone with the LLM stack.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class DiagnosticsReport:
18
+ """Runtime diagnostics report."""
19
+
20
+ timestamp_utc: datetime
21
+ db_path: str
22
+ db_exists: bool
23
+ db_size_bytes: int | None
24
+ db_modified_utc: datetime | None
25
+ config_dir: str
26
+ config_dir_exists: bool
27
+ running_in_container_hint: bool
28
+ persistence_hint: str
29
+ # Embedding subsystem status — populated by interfaces with a
30
+ # CoreContainer in hand (the container injects its own
31
+ # `embedding_status_dict` here).
32
+ embedding_status: dict[str, Any] | None = field(default=None)
@@ -0,0 +1 @@
1
+ """Application services layer."""
@@ -0,0 +1,325 @@
1
+ """Embedding service — generates embeddings and performs hybrid search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from openchronicle.core.domain.models.memory_item import MemoryItem
10
+ from openchronicle.core.domain.ports.embedding_port import EmbeddingPort
11
+
12
+ if TYPE_CHECKING:
13
+ from openchronicle.core.infrastructure.persistence.sqlite_store import SqliteStore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # RRF constant — standard value from the original RRF paper
18
+ _RRF_K = 60
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BackfillResult:
23
+ """Outcome of a backfill run.
24
+
25
+ The per-item resilience in ``generate_missing`` keeps a single bad item
26
+ from blocking the rest, but it must NOT hide total failure from callers.
27
+ Carrying ``failed`` alongside ``generated`` lets the MCP/API/CLI surfaces
28
+ return an honest status to clients.
29
+ """
30
+
31
+ generated: int
32
+ failed: int
33
+ elapsed_ms: int
34
+
35
+
36
+ class EmbeddingService:
37
+ """Coordinates embedding generation and hybrid (FTS5 + semantic) search."""
38
+
39
+ def __init__(self, port: EmbeddingPort, store: SqliteStore) -> None:
40
+ self._port = port
41
+ self._store = store
42
+ # Degraded-search bookkeeping — flips on when the embedding
43
+ # provider raises during a search, flips off the next time
44
+ # it succeeds. Surfaced via container.embedding_status_dict
45
+ # for /api/v1/health.
46
+ self._search_failure_count: int = 0
47
+ self._last_failure_at: str | None = None
48
+
49
+ @property
50
+ def port(self) -> EmbeddingPort:
51
+ return self._port
52
+
53
+ @property
54
+ def search_failure_count(self) -> int:
55
+ return self._search_failure_count
56
+
57
+ @property
58
+ def last_failure_at(self) -> str | None:
59
+ return self._last_failure_at
60
+
61
+ def generate_for_memory(
62
+ self,
63
+ memory_id: str,
64
+ content: str,
65
+ *,
66
+ force: bool = False,
67
+ ) -> None:
68
+ """Generate and store embedding for a single memory item.
69
+
70
+ Skips generation if an embedding already exists with the same model,
71
+ unless ``force`` is True (used when content changes).
72
+ """
73
+ if not force:
74
+ existing_model = self._store.get_embedding_model(memory_id)
75
+ if existing_model == self._port.model_name():
76
+ return
77
+
78
+ vec = self._port.embed(content)
79
+ self._store.save_embedding(
80
+ memory_id,
81
+ vec,
82
+ model=self._port.model_name(),
83
+ dimensions=self._port.dimensions(),
84
+ )
85
+
86
+ def generate_missing(self, *, project_id: str | None = None, force: bool = False) -> BackfillResult:
87
+ """Backfill embeddings for memories that don't have one.
88
+
89
+ If *force* is True, regenerate all embeddings (model change scenario).
90
+ Individual failures are logged and skipped so the backfill always
91
+ completes — but the failure count is returned so callers can surface
92
+ a partial/total-failure status instead of falsely reporting "ok".
93
+ """
94
+ import time
95
+
96
+ items = self._store.list_memory(limit=None, pinned_only=False)
97
+ if project_id:
98
+ items = [i for i in items if i.project_id == project_id]
99
+
100
+ candidates = []
101
+ for item in items:
102
+ if not force:
103
+ existing_model = self._store.get_embedding_model(item.id)
104
+ if existing_model == self._port.model_name():
105
+ continue
106
+ candidates.append(item)
107
+
108
+ if not candidates:
109
+ logger.info("Embedding backfill: 0 candidates, nothing to do")
110
+ return BackfillResult(generated=0, failed=0, elapsed_ms=0)
111
+
112
+ logger.info(
113
+ "Embedding backfill started: %d candidates (model=%s, force=%s)",
114
+ len(candidates),
115
+ self._port.model_name(),
116
+ force,
117
+ )
118
+
119
+ t0 = time.monotonic()
120
+ count = 0
121
+ failed = 0
122
+ for item in candidates:
123
+ try:
124
+ vec = self._port.embed(item.content)
125
+ self._store.save_embedding(
126
+ item.id,
127
+ vec,
128
+ model=self._port.model_name(),
129
+ dimensions=self._port.dimensions(),
130
+ )
131
+ count += 1
132
+ except Exception:
133
+ failed += 1
134
+ logger.warning("Embedding generation failed for memory %s", item.id, exc_info=True)
135
+
136
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
137
+ logger.info(
138
+ "Embedding backfill completed: %d generated, %d failed, %dms elapsed",
139
+ count,
140
+ failed,
141
+ elapsed_ms,
142
+ )
143
+ return BackfillResult(generated=count, failed=failed, elapsed_ms=elapsed_ms)
144
+
145
+ def embedding_status(self) -> dict[str, int]:
146
+ """Return embedding coverage stats."""
147
+ total_memories = self._store.count_memory()
148
+ embedded = self._store.count_embeddings()
149
+ stale = self._store.count_stale_embeddings(self._port.model_name())
150
+ return {
151
+ "total_memories": total_memories,
152
+ "embedded": embedded,
153
+ "missing": total_memories - embedded,
154
+ "stale": stale,
155
+ }
156
+
157
+ def search_hybrid(
158
+ self,
159
+ query: str,
160
+ *,
161
+ top_k: int = 8,
162
+ project_id: str | None = None,
163
+ include_pinned: bool = True,
164
+ tags: list[str] | None = None,
165
+ offset: int = 0,
166
+ ) -> list[MemoryItem]:
167
+ """Hybrid search: FTS5 keyword + embedding similarity via RRF.
168
+
169
+ 1. Run keyword search (FTS5) for ranked list A
170
+ 2. Embed query → cosine similarity → ranked list B
171
+ 3. Combine via Reciprocal Rank Fusion
172
+ 4. Return top_k results
173
+ """
174
+ effective_top_k = top_k + offset
175
+
176
+ # ── Pinned items (always included) ──────────────────────────────
177
+ pinned_items: list[MemoryItem] = []
178
+ if include_pinned:
179
+ pinned_items = self._store.pinned_items(project_id)
180
+ if tags:
181
+ pinned_items = [i for i in pinned_items if all(t in i.tags for t in tags)]
182
+
183
+ # Pinned items have separate budget — don't reduce search/RRF limit
184
+ # (prevents pinned items from crowding out query-relevant results)
185
+
186
+ pinned_ids = {i.id for i in pinned_items}
187
+
188
+ # ── Keyword search (list A) ─────────────────────────────────────
189
+ keyword_results = self._store.search_memory(
190
+ query,
191
+ top_k=effective_top_k * 2, # over-fetch for RRF merge
192
+ project_id=project_id,
193
+ include_pinned=False,
194
+ tags=tags,
195
+ )
196
+ keyword_results = [i for i in keyword_results if i.id not in pinned_ids]
197
+
198
+ # ── Semantic search (list B) ─────────────────────────────────────
199
+ # Embedding-failure degradation: if the provider raises, log it,
200
+ # mark the service degraded, and return FTS5-only results. The
201
+ # caller never sees the exception; /api/v1/health surfaces the
202
+ # degraded state via the failure counters on the service.
203
+ try:
204
+ semantic_ranked = self._semantic_search(
205
+ query,
206
+ project_id=project_id,
207
+ tags=tags,
208
+ exclude_ids=pinned_ids,
209
+ limit=effective_top_k * 2,
210
+ )
211
+ # Successful call clears any prior degraded marker.
212
+ if self._search_failure_count:
213
+ logger.info(
214
+ "embedding search recovered after %d prior failures",
215
+ self._search_failure_count,
216
+ )
217
+ self._search_failure_count = 0
218
+ except Exception as exc:
219
+ from datetime import UTC, datetime
220
+
221
+ self._search_failure_count += 1
222
+ self._last_failure_at = datetime.now(UTC).isoformat()
223
+ logger.warning(
224
+ "embedding search failed (%d total); degrading to FTS5-only: %s",
225
+ self._search_failure_count,
226
+ exc,
227
+ )
228
+ non_pinned_page = keyword_results[offset : offset + top_k]
229
+ if offset == 0:
230
+ return list(pinned_items) + non_pinned_page
231
+ return non_pinned_page
232
+
233
+ # ── RRF merge ──────────────────────────────────────────────────
234
+ keyword_rank: dict[str, int] = {item.id: rank for rank, item in enumerate(keyword_results, start=1)}
235
+ semantic_rank: dict[str, int] = {mid: rank for rank, mid in enumerate(semantic_ranked, start=1)}
236
+
237
+ all_ids = set(keyword_rank) | set(semantic_rank)
238
+ # Build lookup for MemoryItem objects
239
+ item_map: dict[str, MemoryItem] = {i.id: i for i in keyword_results}
240
+
241
+ # For semantic-only results, fetch MemoryItem from store
242
+ for mid in semantic_rank:
243
+ if mid not in item_map:
244
+ mem = self._store.get_memory(mid)
245
+ if mem:
246
+ item_map[mid] = mem
247
+
248
+ rrf_scores: list[tuple[str, float]] = []
249
+ for mid in all_ids:
250
+ if mid not in item_map:
251
+ continue
252
+ # Apply tag filter to semantic-only results
253
+ if tags and not all(t in item_map[mid].tags for t in tags):
254
+ continue
255
+ # Apply project filter to semantic-only results
256
+ if project_id and item_map[mid].project_id != project_id:
257
+ continue
258
+
259
+ kr = keyword_rank.get(mid)
260
+ sr = semantic_rank.get(mid)
261
+ score = 0.0
262
+ if kr is not None:
263
+ score += 1.0 / (_RRF_K + kr)
264
+ if sr is not None:
265
+ score += 1.0 / (_RRF_K + sr)
266
+ rrf_scores.append((mid, score))
267
+
268
+ rrf_scores.sort(key=lambda x: x[1], reverse=True)
269
+
270
+ merged = [item_map[mid] for mid, _ in rrf_scores[:effective_top_k]]
271
+
272
+ # Pinned items prepended on first page only; offset paginates non-pinned results
273
+ non_pinned_page = merged[offset : offset + top_k]
274
+ if offset == 0:
275
+ return list(pinned_items) + non_pinned_page
276
+ return non_pinned_page
277
+
278
+ def _semantic_search(
279
+ self,
280
+ query: str,
281
+ *,
282
+ project_id: str | None = None,
283
+ tags: list[str] | None = None,
284
+ exclude_ids: set[str] | None = None,
285
+ limit: int = 16,
286
+ ) -> list[str]:
287
+ """Return memory IDs ranked by cosine similarity to query embedding.
288
+
289
+ All adapters normalize at output, so dot product = cosine similarity.
290
+ Numpy single-matmul replaces a per-item Python loop; for ~5k memories
291
+ at 1536 dims this is ~50-100x faster than the prior pure-Python path.
292
+ Memory cost is unchanged: list_embeddings still loads the full
293
+ embedding table — that's the architectural ceiling addressed by a
294
+ future move to a vector-indexed store (sqlite-vec).
295
+ """
296
+ import numpy as np
297
+
298
+ query_vec = self._port.embed(query)
299
+ all_embeddings = self._store.list_embeddings()
300
+
301
+ if not all_embeddings:
302
+ return []
303
+
304
+ ids = [mid for mid in all_embeddings if mid not in exclude_ids] if exclude_ids else list(all_embeddings)
305
+ if not ids:
306
+ return []
307
+
308
+ matrix = np.asarray([all_embeddings[mid] for mid in ids], dtype=np.float32)
309
+ q = np.asarray(query_vec, dtype=np.float32)
310
+ scores = matrix @ q # (N,) cosine similarities
311
+
312
+ # argpartition gives top-k unsorted in O(N); sort the slice for ranks.
313
+ k = min(limit, scores.shape[0])
314
+ top_unsorted = np.argpartition(-scores, k - 1)[:k] if k < scores.shape[0] else np.arange(scores.shape[0])
315
+ top_sorted = top_unsorted[np.argsort(-scores[top_unsorted])]
316
+ return [ids[i] for i in top_sorted]
317
+
318
+
319
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
320
+ """Dot product of unit vectors = cosine similarity.
321
+
322
+ Kept as a small helper for tests + diagnostic callers. The hot search
323
+ path uses numpy (see _semantic_search).
324
+ """
325
+ return sum(x * y for x, y in zip(a, b, strict=False))