dlightrag-memory 2.0.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,186 @@
1
+ # Copyright 2025-2026 Hanlian Lu. SPDX-License-Identifier: Apache-2.0
2
+ """pg_textsearch BM25 mechanics for the memory sparse leg.
3
+
4
+ A faithful, narrow port of the corpus BM25 quality knobs (same extension,
5
+ same k1/b, same textsearch configs) kept private to this package so the
6
+ memory adapter never depends on dlightrag.engine.rag. Two unconditional indexes —
7
+ ``simple`` and ``public.jiebacfg`` — serve one table; queries hit both and
8
+ merge by best score, so Chinese and Latin bodies keep their tuned configs
9
+ without a per-row language column.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ _FALLBACK_CONFIG = "simple"
19
+ _JIEBA_CONFIG = "public.jiebacfg"
20
+ _INDEX_PREFIX = "idx_dlightrag_memory_records_bm25"
21
+ _INDEX_SUFFIXES = ("simple", "jieba")
22
+ _K1 = 1.2
23
+ _B = 0.75
24
+
25
+ _CONFIG_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$")
26
+
27
+
28
+ def _format_float(value: float) -> str:
29
+ return f"{float(value):g}"
30
+
31
+
32
+ def validate_text_config(text_config: str) -> str:
33
+ value = str(text_config).strip()
34
+ if not _CONFIG_NAME_RE.fullmatch(value):
35
+ raise ValueError(f"unsafe BM25 text_config: {text_config!r}")
36
+ return value
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BM25IndexOptions:
41
+ """One memory-table BM25 index with the corpus-tuned parameters."""
42
+
43
+ index_name: str
44
+ text_config: str
45
+ k1: float = _K1
46
+ b: float = _B
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.k1 <= 0:
50
+ raise ValueError("BM25 k1 must be positive")
51
+ if not 0 <= self.b <= 1:
52
+ raise ValueError("BM25 b must be between 0 and 1")
53
+ validate_text_config(self.text_config)
54
+
55
+ def create_index_sql(self) -> str:
56
+ config = validate_text_config(self.text_config)
57
+ return (
58
+ f"CREATE INDEX {self.index_name} ON dlightrag_memory_records "
59
+ f"USING bm25(body) WITH (text_config='{config}', "
60
+ f"k1={_format_float(self.k1)}, b={_format_float(self.b)})"
61
+ )
62
+
63
+ def matches_indexdef(self, indexdef: str | None) -> bool:
64
+ if not indexdef:
65
+ return False
66
+ normalized = re.sub(r"\s+", "", indexdef.lower().replace('"', "").replace("'", ""))
67
+ config = validate_text_config(self.text_config).lower()
68
+ return (
69
+ self.index_name.lower() in normalized
70
+ and "usingbm25(body)" in normalized
71
+ and (
72
+ f"text_config={config}" in normalized
73
+ or f"text_config={config}::regconfig" in normalized
74
+ )
75
+ and f"k1={_format_float(self.k1)}" in normalized
76
+ and f"b={_format_float(self.b)}" in normalized
77
+ )
78
+
79
+
80
+ def index_name(suffix: str) -> str:
81
+ return _validate_index_name(f"{_INDEX_PREFIX}_{suffix}")
82
+
83
+
84
+ def _validate_index_name(name: str) -> str:
85
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
86
+ raise ValueError(f"unsafe index name: {name!r}")
87
+ return name
88
+
89
+
90
+ def extension_bootstrap_sql() -> tuple[str, ...]:
91
+ """The extensions the sparse leg needs, bootstrapped like root does."""
92
+ return (
93
+ "CREATE EXTENSION IF NOT EXISTS pg_textsearch",
94
+ "CREATE EXTENSION IF NOT EXISTS pg_jieba",
95
+ )
96
+
97
+
98
+ async def text_configs_available(conn: Any) -> tuple[str, ...]:
99
+ """Return the installed textsearch configs this adapter can serve.
100
+
101
+ ``simple`` is pg_catalog built-in; ``public.jiebacfg`` comes from the
102
+ pg_jieba extension, matching the corpus BM25 profiles.
103
+ """
104
+ jieba = await conn.fetchval(
105
+ """
106
+ SELECT 1
107
+ FROM pg_ts_config c
108
+ JOIN pg_namespace n ON n.oid = c.cfgnamespace
109
+ WHERE n.nspname = 'public' AND c.cfgname = 'jiebacfg'
110
+ LIMIT 1
111
+ """
112
+ )
113
+ return (_FALLBACK_CONFIG, _JIEBA_CONFIG) if jieba else (_FALLBACK_CONFIG,)
114
+
115
+
116
+ def desired_indexes(available: tuple[str, ...]) -> tuple[BM25IndexOptions, ...]:
117
+ """The indexes to provision: simple always, jieba when installed."""
118
+ options = [BM25IndexOptions(index_name=index_name("simple"), text_config=_FALLBACK_CONFIG)]
119
+ if _JIEBA_CONFIG in available:
120
+ options.append(BM25IndexOptions(index_name=index_name("jieba"), text_config=_JIEBA_CONFIG))
121
+ return tuple(options)
122
+
123
+
124
+ def build_bm25_sql(*, index_name: str, limit: int) -> str:
125
+ safe_index = _validate_index_name(index_name)
126
+ limit_value = int(limit)
127
+ if limit_value < 1:
128
+ raise ValueError("BM25 limit must be positive")
129
+ return ( # noqa: S608 - interpolates only the validated index name
130
+ "SELECT owner_id, memory_id, kind, body, normalized_body, " # noqa: S608
131
+ "origin_kind, origin_id, run_id, session_id, status, supersedes_id, "
132
+ "embedding_fingerprint, "
133
+ "created_at, updated_at, "
134
+ f"-(body <@> to_bm25query($1, '{safe_index}')) AS score " # noqa: S608
135
+ "FROM dlightrag_memory_records "
136
+ "WHERE owner_id = $2 AND status = 'active' "
137
+ f"ORDER BY body <@> to_bm25query($1, '{safe_index}') " # noqa: S608
138
+ "LIMIT " + str(limit_value)
139
+ )
140
+
141
+
142
+ async def ensure_bm25_indexes(
143
+ conn: Any,
144
+ *,
145
+ available: tuple[str, ...] | None = None,
146
+ verify_only: bool = False,
147
+ ) -> tuple[str, ...]:
148
+ """Provision or validate the memory-table BM25 indexes.
149
+
150
+ With ``verify_only`` this performs no DDL: readers load the served index
151
+ names and fail when a configured index is missing, matching the corpus
152
+ verify path.
153
+ """
154
+ installed = available if available is not None else await text_configs_available(conn)
155
+ options = desired_indexes(installed)
156
+ for option in options:
157
+ indexdef = await conn.fetchval(
158
+ "SELECT indexdef FROM pg_indexes WHERE indexname = $1", option.index_name
159
+ )
160
+ if option.matches_indexdef(indexdef):
161
+ continue
162
+ if verify_only:
163
+ raise RuntimeError(
164
+ f"BM25 index {option.index_name} is missing or does not match configured "
165
+ "options; initialize it on the writer first"
166
+ )
167
+ if indexdef:
168
+ await conn.execute(f"DROP INDEX IF EXISTS {option.index_name}")
169
+ await conn.execute(option.create_index_sql())
170
+ if not verify_only:
171
+ for suffix in _INDEX_SUFFIXES:
172
+ if not any(option.index_name == index_name(suffix) for option in options):
173
+ await conn.execute(f"DROP INDEX IF EXISTS {index_name(suffix)}")
174
+ return tuple(option.index_name for option in options)
175
+
176
+
177
+ __all__ = [
178
+ "BM25IndexOptions",
179
+ "build_bm25_sql",
180
+ "desired_indexes",
181
+ "ensure_bm25_indexes",
182
+ "extension_bootstrap_sql",
183
+ "index_name",
184
+ "text_configs_available",
185
+ "validate_text_config",
186
+ ]
@@ -0,0 +1,25 @@
1
+ # Copyright 2025-2026 Hanlian Lu. SPDX-License-Identifier: Apache-2.0
2
+ """Public exceptions one Memory operation may raise."""
3
+
4
+
5
+ class MemoryWriteRejectedError(Exception):
6
+ """A named Memory Write failed the closed checklist."""
7
+
8
+ error_kind = "memory_write_rejected"
9
+
10
+ def __init__(self, public_message: str) -> None:
11
+ super().__init__(public_message)
12
+ self.public_message = public_message
13
+
14
+
15
+ class MemoryUnavailableError(Exception):
16
+ """This principal cannot write or auto-recall Memory Records."""
17
+
18
+ error_kind = "memory_unavailable"
19
+
20
+ def __init__(self) -> None:
21
+ super().__init__("Profile Memory is unavailable for this caller.")
22
+ self.public_message = "Profile Memory is unavailable for this caller."
23
+
24
+
25
+ __all__ = ["MemoryUnavailableError", "MemoryWriteRejectedError"]
@@ -0,0 +1,28 @@
1
+ # Copyright 2025-2026 Hanlian Lu. SPDX-License-Identifier: Apache-2.0
2
+ """Reciprocal Rank Fusion for recall leg rankings.
3
+
4
+ The industry-standard fusion (MemMachine ships RRF(k=60) over identity + BM25
5
+ rankings). Each leg contributes an ordered ranking of record ids; every rank
6
+ position adds ``1 / (k + rank)`` to that id's fused score. Rank-based, never
7
+ score-averaging, so legs with different score scales stay comparable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+
14
+ RRF_K = 60
15
+
16
+
17
+ def rrf_fuse(leg_rankings: Sequence[Sequence[str]], *, k: int = RRF_K) -> dict[str, float]:
18
+ """Return one id -> fused RRF score map from per-leg ordered rankings."""
19
+ if k < 1:
20
+ raise ValueError("RRF k must be positive")
21
+ scores: dict[str, float] = {}
22
+ for ranking in leg_rankings:
23
+ for rank, memory_id in enumerate(ranking, start=1):
24
+ scores[memory_id] = scores.get(memory_id, 0.0) + 1.0 / (k + rank)
25
+ return scores
26
+
27
+
28
+ __all__ = ["RRF_K", "rrf_fuse"]
@@ -0,0 +1,241 @@
1
+ # Copyright 2025-2026 Hanlian Lu. SPDX-License-Identifier: Apache-2.0
2
+ """Subject-bound stdio MCP host for the independent Profile Memory package."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from mcp.server import MCPServer
11
+ from mcp.types import ToolAnnotations
12
+ from pydantic import Field
13
+
14
+ from dlightrag_memory import (
15
+ Memory,
16
+ MemoryOperationReceipt,
17
+ MemoryProvenance,
18
+ MemoryWriteRejectedError,
19
+ __version__,
20
+ )
21
+ from dlightrag_memory.policy import MEMORY_BODY_LIMIT
22
+ from dlightrag_memory.postgres import PostgresMemoryStore
23
+
24
+ SERVER_NAME = "dlightrag-memory"
25
+ _MemoryKind = Literal["preference", "fact"]
26
+
27
+
28
+ async def _recall(memory: Memory, *, subject: str, query: str) -> dict[str, Any]:
29
+ result = await memory.recall(owner_id=subject, query=query)
30
+ return {
31
+ "records": [
32
+ {"memory_id": record.memory_id, "kind": record.kind, "body": record.body}
33
+ for record in result.records
34
+ ]
35
+ }
36
+
37
+
38
+ async def _remember(
39
+ memory: Memory,
40
+ *,
41
+ subject: str,
42
+ kind: _MemoryKind,
43
+ body: str,
44
+ supersedes_id: str | None,
45
+ idempotency_key: str,
46
+ ) -> dict[str, Any]:
47
+ receipt = await memory.remember(
48
+ owner_id=subject,
49
+ kind=kind,
50
+ body=body,
51
+ provenance=_provenance(idempotency_key),
52
+ supersedes_id=supersedes_id,
53
+ idempotency_key=f"mcp:{subject}:{idempotency_key}",
54
+ )
55
+ return _receipt(receipt)
56
+
57
+
58
+ async def _forget(
59
+ memory: Memory,
60
+ *,
61
+ subject: str,
62
+ memory_id: str | None,
63
+ body: str | None,
64
+ idempotency_key: str,
65
+ ) -> dict[str, Any]:
66
+ receipt = await memory.forget(
67
+ owner_id=subject,
68
+ memory_id=memory_id,
69
+ body=body,
70
+ provenance=_provenance(idempotency_key),
71
+ idempotency_key=f"mcp:{subject}:{idempotency_key}",
72
+ )
73
+ return _receipt(receipt)
74
+
75
+
76
+ async def _undo(
77
+ memory: Memory,
78
+ *,
79
+ subject: str,
80
+ change_id: str,
81
+ idempotency_key: str,
82
+ ) -> dict[str, Any]:
83
+ receipt = await memory.undo(
84
+ owner_id=subject,
85
+ change_id=change_id,
86
+ provenance=MemoryProvenance(
87
+ origin_kind="undo",
88
+ origin_id=f"mcp:{subject}:{idempotency_key}",
89
+ ),
90
+ idempotency_key=f"mcp:{subject}:{idempotency_key}",
91
+ )
92
+ return _receipt(receipt)
93
+
94
+
95
+ def build_memory_server(memory: Memory, *, subject: str) -> MCPServer:
96
+ """One authorized server with four tools over one bound subject."""
97
+ if not subject.strip():
98
+ raise ValueError("a memory subject is required")
99
+ server = MCPServer(SERVER_NAME, version=__version__, log_level="INFO")
100
+
101
+ @server.tool(
102
+ name="memory_recall",
103
+ description=(
104
+ "Recall owner preferences and facts relevant to a query, including ids needed "
105
+ "before replacing or forgetting one. Context only; never citable."
106
+ ),
107
+ annotations=ToolAnnotations(read_only_hint=True),
108
+ )
109
+ async def memory_recall(
110
+ query: Annotated[str, Field(min_length=1, description="What to recall memories for.")],
111
+ ) -> dict[str, Any]:
112
+ return await _recall(memory, subject=subject, query=query)
113
+
114
+ @server.tool(
115
+ name="memory_remember",
116
+ description=(
117
+ "Store one durable owner preference or fact. Do not store task state, research "
118
+ "claims, citations, transcripts, credentials, or private keys."
119
+ ),
120
+ )
121
+ async def memory_remember(
122
+ kind: Annotated[_MemoryKind, Field(description="preference or fact")],
123
+ body: Annotated[
124
+ str,
125
+ Field(min_length=1, max_length=MEMORY_BODY_LIMIT, description="What to remember."),
126
+ ],
127
+ supersedes_id: Annotated[
128
+ str | None, Field(default=None, description="Active memory id this replaces.")
129
+ ],
130
+ idempotency_key: Annotated[
131
+ str,
132
+ Field(
133
+ min_length=1,
134
+ max_length=255,
135
+ description="Stable mutation key reused verbatim when retrying this operation.",
136
+ ),
137
+ ],
138
+ ) -> dict[str, Any]:
139
+ try:
140
+ return await _remember(
141
+ memory,
142
+ subject=subject,
143
+ kind=kind,
144
+ body=body,
145
+ supersedes_id=supersedes_id,
146
+ idempotency_key=idempotency_key,
147
+ )
148
+ except MemoryWriteRejectedError as exc:
149
+ raise ValueError(exc.public_message) from exc
150
+
151
+ @server.tool(
152
+ name="memory_forget",
153
+ description="Idempotently forget one active preference or fact by id or exact body.",
154
+ )
155
+ async def memory_forget(
156
+ memory_id: Annotated[
157
+ str | None, Field(default=None, description="Id of the memory to forget.")
158
+ ],
159
+ body: Annotated[
160
+ str | None, Field(default=None, description="Exact body if the id is unknown.")
161
+ ],
162
+ idempotency_key: Annotated[
163
+ str,
164
+ Field(min_length=1, max_length=255, description="Stable retry key."),
165
+ ],
166
+ ) -> dict[str, Any]:
167
+ try:
168
+ return await _forget(
169
+ memory,
170
+ subject=subject,
171
+ memory_id=memory_id,
172
+ body=body,
173
+ idempotency_key=idempotency_key,
174
+ )
175
+ except MemoryWriteRejectedError as exc:
176
+ raise ValueError(exc.public_message) from exc
177
+
178
+ @server.tool(
179
+ name="memory_undo",
180
+ description="Compensate one still-current Memory change by its change id.",
181
+ )
182
+ async def memory_undo(
183
+ change_id: Annotated[str, Field(min_length=1, description="Change id to undo.")],
184
+ idempotency_key: Annotated[
185
+ str,
186
+ Field(min_length=1, max_length=255, description="Stable retry key."),
187
+ ],
188
+ ) -> dict[str, Any]:
189
+ try:
190
+ return await _undo(
191
+ memory,
192
+ subject=subject,
193
+ change_id=change_id,
194
+ idempotency_key=idempotency_key,
195
+ )
196
+ except MemoryWriteRejectedError as exc:
197
+ raise ValueError(exc.public_message) from exc
198
+
199
+ return server
200
+
201
+
202
+ def _provenance(idempotency_key: str) -> MemoryProvenance:
203
+ return MemoryProvenance(origin_kind="mcp", origin_id=idempotency_key)
204
+
205
+
206
+ def _receipt(receipt: MemoryOperationReceipt) -> dict[str, Any]:
207
+ return {
208
+ "action": receipt.action,
209
+ "body": receipt.body,
210
+ "change_id": receipt.change_id,
211
+ "kind": receipt.kind,
212
+ "memory_ids": list(receipt.memory_ids),
213
+ "outcome": receipt.outcome,
214
+ "supersedes_id": receipt.supersedes_id,
215
+ "target_change_id": receipt.target_change_id,
216
+ }
217
+
218
+
219
+ def _arguments() -> argparse.Namespace:
220
+ parser = argparse.ArgumentParser(prog="dlightrag-memory-mcp")
221
+ parser.add_argument("--dsn", required=True, help="PostgreSQL connection string")
222
+ parser.add_argument("--subject", required=True, help="Owner subject every tool is bound to")
223
+ return parser.parse_args()
224
+
225
+
226
+ def main() -> None:
227
+ args = _arguments()
228
+
229
+ async def run() -> None:
230
+ store = PostgresMemoryStore(dsn=args.dsn)
231
+ await store.initialize()
232
+ server = build_memory_server(Memory(store), subject=args.subject)
233
+ try:
234
+ await server.run_stdio_async()
235
+ finally:
236
+ await store.aclose()
237
+
238
+ asyncio.run(run())
239
+
240
+
241
+ __all__ = ["SERVER_NAME", "build_memory_server", "main"]