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.
- dlightrag_memory/__init__.py +37 -0
- dlightrag_memory/_storage/__init__.py +0 -0
- dlightrag_memory/_storage/pg.py +1311 -0
- dlightrag_memory/_storage/pg_bm25.py +186 -0
- dlightrag_memory/errors.py +25 -0
- dlightrag_memory/fusion.py +28 -0
- dlightrag_memory/mcp_server.py +241 -0
- dlightrag_memory/memory.py +260 -0
- dlightrag_memory/models.py +95 -0
- dlightrag_memory/normalize.py +24 -0
- dlightrag_memory/policy.py +82 -0
- dlightrag_memory/ports.py +79 -0
- dlightrag_memory/postgres.py +13 -0
- dlightrag_memory/py.typed +0 -0
- dlightrag_memory/recall.py +25 -0
- dlightrag_memory/store.py +676 -0
- dlightrag_memory-2.0.0.dist-info/METADATA +12 -0
- dlightrag_memory-2.0.0.dist-info/RECORD +22 -0
- dlightrag_memory-2.0.0.dist-info/WHEEL +4 -0
- dlightrag_memory-2.0.0.dist-info/entry_points.txt +2 -0
- dlightrag_memory-2.0.0.dist-info/licenses/LICENSE +199 -0
- dlightrag_memory-2.0.0.dist-info/licenses/NOTICE +14 -0
|
@@ -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"]
|