memcore-sdk 0.3.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.
memcore/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """memcore — 通用记忆内核的 Python SDK。
2
+
3
+ from memcore import Memcore
4
+ mc = Memcore(api_key="mk_live_...")
5
+ user = mc.subject("user-42")
6
+ user.remember("我住在上海")
7
+ print(user.recall("我住在哪").as_prompt())
8
+ """
9
+
10
+ from memcore.client import AsyncMemcore, AsyncSubject, Memcore, Subject
11
+ from memcore.models import MemcoreError, Memory, Profile, ProfileSlot, RecallResult, Why
12
+
13
+ __version__ = "0.3.0"
14
+ __all__ = ["Memcore", "AsyncMemcore", "Subject", "AsyncSubject", "Memory", "Profile",
15
+ "ProfileSlot", "RecallResult", "Why", "MemcoreError"]
memcore/client.py ADDED
@@ -0,0 +1,338 @@
1
+ """memcore Python SDK。
2
+
3
+ from memcore import Memcore
4
+
5
+ mc = Memcore(api_key="mk_live_...")
6
+ user = mc.subject("user-42") # 绑定主体,省得每次都传
7
+
8
+ user.remember("我住在上海,在做数据库产品")
9
+ hits = user.recall("他住在哪", explain=True)
10
+ print(hits.as_prompt()) # 直接塞进你的 system prompt
11
+
12
+ 设计取舍:
13
+ - 同步 `Memcore` 与异步 `AsyncMemcore` 对等,方法名完全一致
14
+ - `subject()` 返回绑定主体的句柄——绝大多数调用都是"针对某个用户",不该重复传参
15
+ - 只依赖 httpx;返回轻量 dataclass,不给调用方塞 pydantic
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from memcore.models import MemcoreError, Memory, Profile, RecallResult
28
+
29
+ DEFAULT_BASE_URL = os.environ.get("MEMCORE_BASE_URL", "https://api.reglos.ai")
30
+ _TIMEOUT = 30.0
31
+
32
+
33
+ def _params(subject: str, **kw) -> dict:
34
+ out: dict[str, Any] = {"subject": subject}
35
+ for k, v in kw.items():
36
+ if v is None or v is False:
37
+ continue
38
+ out[k] = v.isoformat() if isinstance(v, datetime) else v
39
+ return out
40
+
41
+
42
+ def _check(r: httpx.Response) -> dict:
43
+ if r.status_code >= 400:
44
+ try:
45
+ msg = r.json().get("detail", r.text)
46
+ except Exception:
47
+ msg = r.text
48
+ raise MemcoreError(r.status_code, str(msg))
49
+ return r.json()
50
+
51
+
52
+ class _BaseSubject:
53
+ """绑定了某个主体的句柄。所有方法都不必再传 subject。"""
54
+
55
+ def __init__(self, client, subject_id: str, subject_type: str = "user"):
56
+ self._c = client
57
+ self.subject_id = subject_id
58
+ self.subject_type = subject_type
59
+
60
+ def _p(self, **kw) -> dict:
61
+ return _params(self.subject_id, subject_type=self.subject_type, **kw)
62
+
63
+
64
+ class Subject(_BaseSubject):
65
+ """同步句柄。"""
66
+
67
+ # ---------------------------------------------------------- 写入
68
+ def remember(
69
+ self,
70
+ text: str | None = None,
71
+ *,
72
+ messages: list[dict] | None = None,
73
+ facts: list[dict] | None = None,
74
+ session_id: str | None = None,
75
+ sync: bool = False,
76
+ ) -> dict:
77
+ """写入记忆。
78
+
79
+ text 最常用:一段自然语言
80
+ messages 对话形式 [{"role","content","at"}];`at` 用于回灌历史(相对时间才解析得对)
81
+ facts 直接写结构化事实(客户已有自己的抽取时用)
82
+ sync True = 同步抽取完再返回(写完立刻可查,代价是延迟)
83
+ """
84
+ body: dict[str, Any] = {}
85
+ if text is not None:
86
+ body["text"] = text
87
+ if messages:
88
+ body["messages"] = messages
89
+ if facts:
90
+ body["facts"] = facts
91
+ if session_id:
92
+ body["session_id"] = session_id
93
+ return _check(
94
+ self._c._http.post(
95
+ "/v1/remember", params=self._p(mode="sync" if sync else "async"), json=body
96
+ )
97
+ )
98
+
99
+ def remember_file(self, path: str | Path) -> dict:
100
+ """文档/图片入记忆。图片走 VLM 双路(描述 + OCR);
101
+ 通用内容只进可检索索引,只有关于该主体的少量事实进画像。"""
102
+ p = Path(path)
103
+ with p.open("rb") as fh:
104
+ return _check(
105
+ self._c._http.post(
106
+ "/v1/documents", params=self._p(), files={"file": (p.name, fh)}
107
+ )
108
+ )
109
+
110
+ # ---------------------------------------------------------- 检索
111
+ def recall(
112
+ self,
113
+ query: str,
114
+ *,
115
+ limit: int | None = None,
116
+ explain: bool = False,
117
+ topic: str | None = None,
118
+ since: datetime | None = None,
119
+ until: datetime | None = None,
120
+ source: str | None = None,
121
+ min_confidence: float | None = None,
122
+ layers: str = "facts,summaries,documents",
123
+ ) -> RecallResult:
124
+ """检索记忆。`explain=True` 会为每条附上"为什么被选中"(命中路径、排名、
125
+ RRF 贡献、有效期状态)与预算统计——调试与合规都用得上。"""
126
+ return RecallResult._from(
127
+ _check(
128
+ self._c._http.get(
129
+ "/v1/recall",
130
+ params=self._p(
131
+ q=query, limit=limit, explain=explain, topic=topic, since=since,
132
+ until=until, source=source, min_confidence=min_confidence,
133
+ include_layers=layers,
134
+ ),
135
+ )
136
+ )
137
+ )
138
+
139
+ def messages(self, *, limit: int = 50, session_id: str | None = None) -> list[dict]:
140
+ """按**时间顺序**读回最近的消息(旧 → 新)。
141
+
142
+ 与 `recall` 的分野:`recall` 回答「关于这个问题,我们记得什么」
143
+ (抽取后的事实,按相关性排序);这里回答「这个人说过什么」
144
+ (原始消息,按时间排序)。**对话历史不能用检索排序冒充。**
145
+ """
146
+ return _check(
147
+ self._c._http.get(
148
+ "/v1/messages", params=self._p(limit=limit, session_id=session_id)
149
+ )
150
+ ).get("messages", [])
151
+
152
+ # ---------------------------------------------------------- 画像
153
+ def profile(self) -> Profile:
154
+ return Profile._from(_check(self._c._http.get("/v1/profile", params=self._p())))
155
+
156
+ def ask(self, question: str) -> dict:
157
+ """自然语言问画像:不必理解我们的槽位结构。返回 {answer, based_on}。"""
158
+ return _check(
159
+ self._c._http.post("/v1/profile/ask", params=self._p(), json={"question": question})
160
+ )
161
+
162
+ # ---------------------------------------------------------- 遗忘
163
+ def forget(
164
+ self,
165
+ memory_id: str | None = None,
166
+ *,
167
+ session_id: str | None = None,
168
+ everything: bool = False,
169
+ reason: str | None = None,
170
+ ) -> dict:
171
+ """三种语义严格区分:
172
+ memory_id 抽错了 → 作废,查询不再命中,但保留痕迹可回溯
173
+ session_id 删一整段会话 → 级联清掉派生事实与向量,不留幽灵
174
+ everything 全清(删除权)→ 返回可存档的删除回执
175
+ """
176
+ body: dict[str, Any] = {"reason": reason}
177
+ if memory_id:
178
+ body["memory_id"] = memory_id
179
+ elif session_id:
180
+ body["session_id"] = session_id
181
+ elif everything:
182
+ body.update({"all": True, "confirm": self.subject_id})
183
+ else:
184
+ raise ValueError("memory_id / session_id / everything 三选一")
185
+ return _check(self._c._http.post("/v1/forget", params=self._p(), json=body))
186
+
187
+ # ---------------------------------------------------------- 溯源与审计
188
+ def explain(self, memory_id: str) -> dict:
189
+ """这条记忆怎么来的:原始出处、生成记录(模型 + prompt 哈希)、被谁取代。"""
190
+ return _check(
191
+ self._c._http.get(f"/v1/memories/{memory_id}/explain", params=self._p())
192
+ )
193
+
194
+ def audit(self, *, since: datetime | None = None, action: str | None = None,
195
+ limit: int = 200) -> list[dict]:
196
+ """审计账本:谁在何时改了哪条、为什么。"""
197
+ return _check(
198
+ self._c._http.get("/v1/audit", params=self._p(since=since, action=action, limit=limit))
199
+ )["entries"]
200
+
201
+
202
+ class Memcore:
203
+ """同步客户端。线程安全:内部 httpx.Client 可复用。"""
204
+
205
+ def __init__(
206
+ self,
207
+ api_key: str | None = None,
208
+ base_url: str = DEFAULT_BASE_URL,
209
+ timeout: float = _TIMEOUT,
210
+ ):
211
+ self._http = httpx.Client(
212
+ base_url=base_url.rstrip("/"),
213
+ timeout=timeout,
214
+ headers={"X-API-Key": api_key} if api_key else {},
215
+ )
216
+
217
+ def subject(self, subject_id: str, subject_type: str = "user") -> Subject:
218
+ """绑定一个记忆主体(终端用户 / Agent / 团队)。"""
219
+ return Subject(self, subject_id, subject_type)
220
+
221
+ def health(self) -> dict:
222
+ return _check(self._http.get("/healthz"))
223
+
224
+ def close(self) -> None:
225
+ self._http.close()
226
+
227
+ def __enter__(self) -> Memcore:
228
+ return self
229
+
230
+ def __exit__(self, *exc) -> None:
231
+ self.close()
232
+
233
+
234
+ class AsyncSubject(_BaseSubject):
235
+ """异步句柄,方法与 Subject 一一对应。"""
236
+
237
+ async def remember(self, text=None, *, messages=None, facts=None,
238
+ session_id=None, sync=False) -> dict:
239
+ body: dict[str, Any] = {}
240
+ if text is not None:
241
+ body["text"] = text
242
+ if messages:
243
+ body["messages"] = messages
244
+ if facts:
245
+ body["facts"] = facts
246
+ if session_id:
247
+ body["session_id"] = session_id
248
+ return _check(
249
+ await self._c._http.post(
250
+ "/v1/remember", params=self._p(mode="sync" if sync else "async"), json=body
251
+ )
252
+ )
253
+
254
+ async def remember_file(self, path: str | Path) -> dict:
255
+ p = Path(path)
256
+ with p.open("rb") as fh:
257
+ return _check(
258
+ await self._c._http.post(
259
+ "/v1/documents", params=self._p(), files={"file": (p.name, fh)}
260
+ )
261
+ )
262
+
263
+ async def recall(self, query: str, *, limit=None, explain=False, topic=None, since=None,
264
+ until=None, source=None, min_confidence=None,
265
+ layers="facts,summaries,documents") -> RecallResult:
266
+ return RecallResult._from(
267
+ _check(
268
+ await self._c._http.get(
269
+ "/v1/recall",
270
+ params=self._p(q=query, limit=limit, explain=explain, topic=topic,
271
+ since=since, until=until, source=source,
272
+ min_confidence=min_confidence, include_layers=layers),
273
+ )
274
+ )
275
+ )
276
+
277
+ async def profile(self) -> Profile:
278
+ return Profile._from(_check(await self._c._http.get("/v1/profile", params=self._p())))
279
+
280
+ async def ask(self, question: str) -> dict:
281
+ return _check(
282
+ await self._c._http.post(
283
+ "/v1/profile/ask", params=self._p(), json={"question": question}
284
+ )
285
+ )
286
+
287
+ async def forget(self, memory_id=None, *, session_id=None, everything=False,
288
+ reason=None) -> dict:
289
+ body: dict[str, Any] = {"reason": reason}
290
+ if memory_id:
291
+ body["memory_id"] = memory_id
292
+ elif session_id:
293
+ body["session_id"] = session_id
294
+ elif everything:
295
+ body.update({"all": True, "confirm": self.subject_id})
296
+ else:
297
+ raise ValueError("memory_id / session_id / everything 三选一")
298
+ return _check(await self._c._http.post("/v1/forget", params=self._p(), json=body))
299
+
300
+ async def explain(self, memory_id: str) -> dict:
301
+ return _check(
302
+ await self._c._http.get(f"/v1/memories/{memory_id}/explain", params=self._p())
303
+ )
304
+
305
+ async def audit(self, *, since=None, action=None, limit=200) -> list[dict]:
306
+ return _check(
307
+ await self._c._http.get(
308
+ "/v1/audit", params=self._p(since=since, action=action, limit=limit)
309
+ )
310
+ )["entries"]
311
+
312
+
313
+ class AsyncMemcore:
314
+ def __init__(self, api_key: str | None = None, base_url: str = DEFAULT_BASE_URL,
315
+ timeout: float = _TIMEOUT):
316
+ self._http = httpx.AsyncClient(
317
+ base_url=base_url.rstrip("/"),
318
+ timeout=timeout,
319
+ headers={"X-API-Key": api_key} if api_key else {},
320
+ )
321
+
322
+ def subject(self, subject_id: str, subject_type: str = "user") -> AsyncSubject:
323
+ return AsyncSubject(self, subject_id, subject_type)
324
+
325
+ async def health(self) -> dict:
326
+ return _check(await self._http.get("/healthz"))
327
+
328
+ async def aclose(self) -> None:
329
+ await self._http.aclose()
330
+
331
+ async def __aenter__(self) -> AsyncMemcore:
332
+ return self
333
+
334
+ async def __aexit__(self, *exc) -> None:
335
+ await self.aclose()
336
+
337
+
338
+ __all__ = ["Memcore", "AsyncMemcore", "Memory", "Profile", "RecallResult", "MemcoreError"]
@@ -0,0 +1,36 @@
1
+ """框架适配:LangChain / LlamaIndex。
2
+
3
+ ## 为什么单独放一个子包
4
+
5
+ 这些适配**不能**成为 SDK 的必需依赖 —— 装个记忆 SDK 顺带拖进整个 LangChain,
6
+ 对不用它的人是纯粹的负担。所以:
7
+
8
+ - `pyproject.toml` 里做成 optional extras(`memcore-sdk[langchain]`)
9
+ - 每个模块在**导入时**才 import 框架,没装就抛一句能读懂的话,
10
+ 而不是 `ModuleNotFoundError: No module named 'langchain_core'` 让人自己猜
11
+
12
+ ## 两个框架各自的接入点
13
+
14
+ | | LangChain | LlamaIndex |
15
+ | --- | --- | --- |
16
+ | 拿记忆当检索源 | `MemcoreRetriever`(`BaseRetriever`) | `MemcoreRetriever`(`BaseRetriever`) |
17
+ | 拿记忆当对话历史 | `MemcoreChatMessageHistory` | `MemcoreMemory`(`BaseMemory`) |
18
+
19
+ **两者的语义差别要清楚**:检索器回答"关于这个问题,我们记得什么";
20
+ 对话历史回答"这个人跟我说过什么"。前者是抽取后的事实,后者是原始消息。
21
+ 混用会让上下文里出现同一件事的两个版本。
22
+ """
23
+
24
+ __all__ = ["require"]
25
+
26
+
27
+ def require(module: str, extra: str) -> None:
28
+ """检查框架是否可用,不可用时抛一句能直接照做的话。"""
29
+ import importlib.util
30
+
31
+ if importlib.util.find_spec(module) is None:
32
+ raise ImportError(
33
+ f"需要 {module},但它没有安装。\n"
34
+ f" pip install 'memcore-sdk[{extra}]'\n"
35
+ f" 或直接:pip install {module}"
36
+ )
@@ -0,0 +1,180 @@
1
+ """LangChain 适配。
2
+
3
+ pip install 'memcore-sdk[langchain]'
4
+
5
+ 两个接入点,语义不同,**别混用**:
6
+
7
+ MemcoreRetriever 「关于这个问题,我们记得什么」→ 抽取后的事实
8
+ MemcoreChatMessageHistory 「这个人跟我说过什么」 → 原始消息
9
+
10
+ 同时塞进上下文会让同一件事出现两个版本(一条事实 + 它的原始出处),
11
+ 既浪费预算又容易让模型自相矛盾。要长期记忆用前者,要多轮上下文用后者。
12
+
13
+ ## 用法
14
+
15
+ ```python
16
+ from memcore import Memcore
17
+ from memcore.integrations.langchain import MemcoreRetriever, MemcoreChatMessageHistory
18
+
19
+ mc = Memcore(api_key="mk_live_…", base_url="https://your-host")
20
+
21
+ # ① 当检索器用
22
+ retriever = MemcoreRetriever(subject=mc.subject("alice"), k=8)
23
+ chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | llm
24
+
25
+ # ② 当对话历史用
26
+ chain_with_history = RunnableWithMessageHistory(
27
+ chain,
28
+ lambda sid: MemcoreChatMessageHistory(subject=mc.subject(sid)),
29
+ input_messages_key="question",
30
+ history_messages_key="history",
31
+ )
32
+ ```
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any
38
+
39
+ from memcore.integrations import require
40
+
41
+ require("langchain_core", "langchain")
42
+
43
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun # noqa: E402
44
+ from langchain_core.chat_history import BaseChatMessageHistory # noqa: E402
45
+ from langchain_core.documents import Document # noqa: E402
46
+ from langchain_core.messages import ( # noqa: E402
47
+ AIMessage,
48
+ BaseMessage,
49
+ HumanMessage,
50
+ SystemMessage,
51
+ )
52
+ from langchain_core.retrievers import BaseRetriever # noqa: E402
53
+
54
+
55
+ def _to_document(m: Any) -> Document:
56
+ """一条记忆 → 一个 Document。
57
+
58
+ **`page_content` 只放陈述本身**,时间、来源、为什么被选中全进 metadata ——
59
+ 把它们拼进正文会污染下游的相似度计算与去重(很多链会对 page_content 做二次处理)。
60
+ """
61
+ meta: dict[str, Any] = {
62
+ "id": m.id,
63
+ "source": "memcore",
64
+ "topic": m.topic,
65
+ "valid_from": m.valid_from,
66
+ "valid_to": m.valid_to,
67
+ # 这条是不是仍然有效。False 表示已被更新的事实取代 ——
68
+ # 仍是历史真相,但下游若要"当前状态"就该过滤掉
69
+ "is_current": m.is_current,
70
+ "provenance": m.provenance,
71
+ }
72
+ if m.score is not None:
73
+ meta["score"] = m.score
74
+ if m.explain_url:
75
+ # 可解释性是我们的差异化 —— 把入口一路带到 Document 上,
76
+ # 下游要追"这条为什么被选中"时不用回头查
77
+ meta["explain_url"] = m.explain_url
78
+ return Document(page_content=m.statement, metadata=meta)
79
+
80
+
81
+ class MemcoreRetriever(BaseRetriever):
82
+ """把 memcore 当 LangChain 检索器用。
83
+
84
+ 与向量库检索器的区别:返回的是**抽取后的事实**,不是原文分块。
85
+ 所以条数少、密度高,`k` 通常比向量库小一个量级。
86
+ """
87
+
88
+ subject: Any
89
+ """`memcore.Memcore(...).subject("alice")` 拿到的对象。"""
90
+
91
+ k: int = 8
92
+ """取多少条。"""
93
+
94
+ topic: str | None = None
95
+ """只取某个主题(可选)。"""
96
+
97
+ min_confidence: float | None = None
98
+
99
+ current_only: bool = True
100
+ """是否只要仍然有效的事实。**默认 True** —— 多数链要的是"现在是什么",
101
+ 而账本里保留着被取代的历史版本;不过滤会让模型同时看到新旧两个说法。"""
102
+
103
+ # 允许 Subject 这种任意对象作为字段值
104
+ model_config = {"arbitrary_types_allowed": True}
105
+
106
+ def _get_relevant_documents(
107
+ self, query: str, *, run_manager: CallbackManagerForRetrieverRun
108
+ ) -> list[Document]:
109
+ res = self.subject.recall(
110
+ query, limit=self.k, topic=self.topic, min_confidence=self.min_confidence
111
+ )
112
+ facts = list(res.facts)
113
+ if self.current_only:
114
+ facts = [f for f in facts if f.is_current]
115
+ return [_to_document(f) for f in facts[: self.k]]
116
+
117
+
118
+ class MemcoreChatMessageHistory(BaseChatMessageHistory):
119
+ """把 memcore 当 LangChain 的对话历史用(`RunnableWithMessageHistory`)。
120
+
121
+ ## 与普通历史存储的两点不同
122
+
123
+ **一、写入是异步抽取的。** `add_messages` 落库即返回,事实抽取在后台队列进行。
124
+ 所以刚写完立刻 `recall` 未必查得到那条事实 —— 但 `messages` 读的是原始消息,
125
+ 不受影响。要写完立刻可查,用 `sync=True`(代价是延迟)。
126
+
127
+ **二、`clear()` 是真的删。** 它调 `/v1/forget` 清掉该主体的全部记忆,
128
+ **不可撤销**。LangChain 的语义里 clear 只是清一个会话的历史,
129
+ 而我们这里没有"只清历史不清记忆"的中间态 —— 所以默认**拒绝执行**,
130
+ 必须显式 `allow_destructive_clear=True` 才动手。
131
+ 宁可让人多写一个参数,也不能让一次误调用清空某个人的全部记忆。
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ subject: Any,
137
+ *,
138
+ limit: int = 50,
139
+ sync: bool = False,
140
+ allow_destructive_clear: bool = False,
141
+ ) -> None:
142
+ self.subject = subject
143
+ self.limit = limit
144
+ self.sync = sync
145
+ self.allow_destructive_clear = allow_destructive_clear
146
+
147
+ @property
148
+ def messages(self) -> list[BaseMessage]:
149
+ """读回原始消息。取不到就返回空 —— 历史缺失不该让整条链挂掉。"""
150
+ try:
151
+ raw = self.subject.messages(limit=self.limit)
152
+ except Exception: # noqa: BLE001
153
+ # 历史读不回来不该让整条链挂掉 —— 退化成"没有历史"
154
+ return []
155
+ out: list[BaseMessage] = []
156
+ for m in raw:
157
+ role, content = m.get("role"), m.get("content") or ""
158
+ if role == "assistant":
159
+ out.append(AIMessage(content=content))
160
+ elif role == "system":
161
+ out.append(SystemMessage(content=content))
162
+ else:
163
+ out.append(HumanMessage(content=content))
164
+ return out
165
+
166
+ def add_messages(self, messages: list[BaseMessage]) -> None:
167
+ payload = []
168
+ for m in messages:
169
+ role = {"ai": "assistant", "human": "user", "system": "system"}.get(m.type, "user")
170
+ payload.append({"role": role, "content": str(m.content)})
171
+ if payload:
172
+ self.subject.remember(messages=payload, sync=self.sync)
173
+
174
+ def clear(self) -> None:
175
+ if not self.allow_destructive_clear:
176
+ raise RuntimeError(
177
+ "clear() 会清空该主体的**全部记忆**(不只是本次会话的历史),且不可撤销。\n"
178
+ "确实要这么做,请构造时传 allow_destructive_clear=True。"
179
+ )
180
+ self.subject.forget(everything=True)
@@ -0,0 +1,198 @@
1
+ """LlamaIndex 适配。
2
+
3
+ pip install 'memcore-sdk[llamaindex]'
4
+
5
+ 两个接入点,语义不同,**别混用**(同 LangChain 那边):
6
+
7
+ MemcoreRetriever 「关于这个问题,我们记得什么」→ 抽取后的事实,按相关性
8
+ MemcoreMemory 「这个人说过什么」 → 原始消息,按时间
9
+
10
+ ## 用法
11
+
12
+ ```python
13
+ from memcore import Memcore
14
+ from memcore.integrations.llama_index import MemcoreRetriever, MemcoreMemory
15
+
16
+ mc = Memcore(api_key="mk_live_…", base_url="https://your-host")
17
+ subject = mc.subject("alice")
18
+
19
+ # ① 当检索器用(可直接进 RetrieverQueryEngine)
20
+ retriever = MemcoreRetriever(subject=subject, k=8)
21
+ nodes = retriever.retrieve("我最喜欢什么乐器?")
22
+
23
+ # ② 当 ChatEngine 的记忆用
24
+ memory = MemcoreMemory.from_defaults(subject=subject)
25
+ ```
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any
31
+
32
+ from memcore.integrations import require
33
+
34
+ require("llama_index.core", "llamaindex")
35
+
36
+ from llama_index.core.base.llms.types import ChatMessage, MessageRole # noqa: E402
37
+ from llama_index.core.memory import BaseMemory # noqa: E402
38
+ from llama_index.core.retrievers import BaseRetriever # noqa: E402
39
+ from llama_index.core.schema import NodeWithScore, QueryBundle, TextNode # noqa: E402
40
+
41
+ _ROLE_IN = {"assistant": MessageRole.ASSISTANT, "system": MessageRole.SYSTEM,
42
+ "user": MessageRole.USER}
43
+ _ROLE_OUT = {MessageRole.ASSISTANT: "assistant", MessageRole.SYSTEM: "system",
44
+ MessageRole.USER: "user"}
45
+
46
+
47
+ def _to_node(m: Any) -> NodeWithScore:
48
+ """一条记忆 → 一个 NodeWithScore。
49
+
50
+ `text` 只放陈述本身,其余进 metadata —— 与 LangChain 那边同样的理由:
51
+ 把时间、来源拼进正文会污染下游的相似度与去重。
52
+
53
+ **`excluded_llm_metadata_keys` 要设。** LlamaIndex 默认会把 metadata
54
+ 一并渲染进给 LLM 的文本里,`id` / `explain_url` 这些对回答毫无帮助,
55
+ 白占上下文预算;但它们要留给调用方做溯源,所以是"排除渲染"而不是不写。
56
+ """
57
+ meta = {
58
+ "id": m.id,
59
+ "source": "memcore",
60
+ "topic": m.topic,
61
+ "valid_from": m.valid_from,
62
+ "valid_to": m.valid_to,
63
+ "is_current": m.is_current,
64
+ "explain_url": m.explain_url,
65
+ }
66
+ node = TextNode(
67
+ id_=m.id or None,
68
+ text=m.statement,
69
+ metadata={k: v for k, v in meta.items() if v is not None},
70
+ excluded_llm_metadata_keys=["id", "explain_url", "source"],
71
+ excluded_embed_metadata_keys=["id", "explain_url", "source"],
72
+ )
73
+ return NodeWithScore(node=node, score=m.score)
74
+
75
+
76
+ class MemcoreRetriever(BaseRetriever):
77
+ """把 memcore 当 LlamaIndex 检索器用。
78
+
79
+ 与向量索引检索器的区别:返回的是**抽取后的事实**,不是原文分块 ——
80
+ 条数少、密度高,`k` 通常比向量库小一个量级。
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ subject: Any,
86
+ *,
87
+ k: int = 8,
88
+ topic: str | None = None,
89
+ min_confidence: float | None = None,
90
+ current_only: bool = True,
91
+ **kwargs: Any,
92
+ ) -> None:
93
+ self._subject = subject
94
+ self._k = k
95
+ self._topic = topic
96
+ self._min_confidence = min_confidence
97
+ # 只要仍然有效的事实。**默认 True** —— 账本里保留着被取代的历史版本,
98
+ # 不过滤会让模型同时看到新旧两个说法。
99
+ self._current_only = current_only
100
+ super().__init__(**kwargs)
101
+
102
+ def _retrieve(self, query_bundle: QueryBundle) -> list[NodeWithScore]:
103
+ res = self._subject.recall(
104
+ query_bundle.query_str, limit=self._k,
105
+ topic=self._topic, min_confidence=self._min_confidence,
106
+ )
107
+ facts = list(res.facts)
108
+ if self._current_only:
109
+ facts = [f for f in facts if f.is_current]
110
+ return [_to_node(f) for f in facts[: self._k]]
111
+
112
+
113
+ class MemcoreMemory(BaseMemory):
114
+ """把 memcore 当 LlamaIndex 的对话记忆用。
115
+
116
+ ## 与 `ChatMemoryBuffer` 的三点不同
117
+
118
+ **一、跨进程、跨设备。** 记忆在服务端,换台机器、换个端都还在 ——
119
+ `ChatMemoryBuffer` 是进程内的。
120
+
121
+ **二、`put` 是异步抽取的。** 落库即返回,事实抽取在后台队列进行。
122
+ `get()` 读的是原始消息,不受影响。
123
+
124
+ **三、`reset()` 默认拒绝执行。** LlamaIndex 的语义里 reset 只是清一段会话,
125
+ 而我们这里它会清掉该主体的**全部记忆**且不可撤销。所以必须显式
126
+ `allow_destructive_reset=True`。宁可让人多写一个参数,
127
+ 也不能让一次误调用清空某个人的全部记忆。
128
+ """
129
+
130
+ # BaseMemory 是 pydantic 模型;这些字段要声明才存得住
131
+ subject: Any = None
132
+ limit: int = 50
133
+ sync: bool = False
134
+ allow_destructive_reset: bool = False
135
+
136
+ model_config = {"arbitrary_types_allowed": True}
137
+
138
+ @classmethod
139
+ def class_name(cls) -> str:
140
+ return "MemcoreMemory"
141
+
142
+ @classmethod
143
+ def from_defaults(cls, **kwargs: Any) -> MemcoreMemory: # type: ignore[override]
144
+ subject = kwargs.pop("subject", None)
145
+ if subject is None:
146
+ raise ValueError("MemcoreMemory 需要 subject=Memcore(...).subject('<id>')")
147
+ return cls(subject=subject, **kwargs)
148
+
149
+ def get(self, input: str | None = None, **kwargs: Any) -> list[ChatMessage]:
150
+ """LlamaIndex 会用它取"要喂给模型的历史"。这里按时间顺序返回原始消息。
151
+
152
+ `input` 参数没用上是**有意的**:它是给"按当前输入裁剪历史"这类实现用的,
153
+ 而我们这里若按 input 去检索,返回的就不是历史而是检索结果 ——
154
+ 那正是 `MemcoreRetriever` 的活。两者混在一个对象里会让上下文出现
155
+ 同一件事的两个版本。
156
+ """
157
+ return self.get_all()
158
+
159
+ def get_all(self) -> list[ChatMessage]:
160
+ try:
161
+ raw = self.subject.messages(limit=self.limit)
162
+ except Exception: # noqa: BLE001
163
+ # 历史读不回来不该让整个 ChatEngine 挂掉 —— 退化成"没有历史"
164
+ return []
165
+ return [
166
+ ChatMessage(
167
+ role=_ROLE_IN.get(m.get("role") or "user", MessageRole.USER),
168
+ content=m.get("content") or "",
169
+ )
170
+ for m in raw
171
+ ]
172
+
173
+ def put(self, message: ChatMessage) -> None:
174
+ role = _ROLE_OUT.get(message.role, "user")
175
+ content = message.content or ""
176
+ if not content:
177
+ return
178
+ self.subject.remember(messages=[{"role": role, "content": content}], sync=self.sync)
179
+
180
+ def set(self, messages: list[ChatMessage]) -> None:
181
+ """LlamaIndex 用它整体覆盖历史。
182
+
183
+ **我们做不到"覆盖"** —— 账本是 ADD-only,事实永不改写。
184
+ 所以这里退化成"把这些消息追加进去",并且**只追加库里还没有的那些**
185
+ (按内容比对最近一段),避免每轮把整段历史重复写一遍。
186
+ """
187
+ existing = {(m.role, m.content) for m in self.get_all()}
188
+ for m in messages:
189
+ if (m.role, m.content or "") not in existing:
190
+ self.put(m)
191
+
192
+ def reset(self) -> None:
193
+ if not self.allow_destructive_reset:
194
+ raise RuntimeError(
195
+ "reset() 会清空该主体的**全部记忆**(不只是本次会话),且不可撤销。\n"
196
+ "确实要这么做,请构造时传 allow_destructive_reset=True。"
197
+ )
198
+ self.subject.forget(everything=True)
memcore/models.py ADDED
@@ -0,0 +1,131 @@
1
+ """SDK 返回类型。用轻量 dataclass 而非 pydantic——SDK 不该给调用方塞依赖。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Why:
11
+ """一条记忆"为什么被检索到"。只有 explain=True 时才有。"""
12
+
13
+ matched_paths: dict[str, Any] = field(default_factory=dict) # 命中的检索路及排名
14
+ rrf_contribution: dict[str, float] = field(default_factory=dict) # 各路对最终分的贡献
15
+ validity: dict[str, Any] = field(default_factory=dict) # current / superseded + 有效期
16
+ confidence: float | None = None
17
+ confirmed_times: int | None = None
18
+
19
+
20
+ @dataclass
21
+ class Memory:
22
+ id: str
23
+ statement: str
24
+ score: float | None = None
25
+ topic: str | None = None
26
+ valid_from: str | None = None
27
+ valid_to: str | None = None
28
+ provenance: str | None = None
29
+ why: Why | None = None
30
+ explain_url: str | None = None
31
+
32
+ @property
33
+ def is_current(self) -> bool:
34
+ """False 表示这条已被更新的事实取代(但仍是历史真相,可用于回溯查询)。"""
35
+ return self.valid_to is None
36
+
37
+ @classmethod
38
+ def _from(cls, d: dict) -> Memory:
39
+ why = d.get("why")
40
+ return cls(
41
+ id=d.get("id", ""),
42
+ statement=d.get("statement", ""),
43
+ score=d.get("score"),
44
+ topic=d.get("topic"),
45
+ valid_from=d.get("valid_from"),
46
+ valid_to=d.get("valid_to"),
47
+ provenance=d.get("provenance"),
48
+ why=Why(**{k: v for k, v in why.items() if k in Why.__annotations__}) if why else None,
49
+ explain_url=d.get("explain_url"),
50
+ )
51
+
52
+ def __str__(self) -> str:
53
+ mark = "" if self.is_current else " (已被取代)"
54
+ return f"{self.statement}{mark}"
55
+
56
+
57
+ @dataclass
58
+ class RecallResult:
59
+ facts: list[Memory] = field(default_factory=list)
60
+ summaries: list[dict] = field(default_factory=list) # L1 会话摘要
61
+ documents: list[dict] = field(default_factory=list) # 文档分块
62
+ messages: list[dict] = field(default_factory=list) # L0 原文片段
63
+ budget: dict | None = None # explain=True 时:用了多少预算、裁掉多少
64
+ timed_out_paths: list[str] = field(default_factory=list)
65
+
66
+ def __iter__(self):
67
+ return iter(self.facts)
68
+
69
+ def __len__(self) -> int:
70
+ return len(self.facts)
71
+
72
+ def as_prompt(self, header: str = "关于该用户你已知的:") -> str:
73
+ """拼成可直接塞进 system prompt 的文本——最常见的用法,省得每家自己写一遍。"""
74
+ if not self.facts:
75
+ return ""
76
+ lines = [f"- {m.statement}" for m in self.facts]
77
+ return f"{header}\n" + "\n".join(lines)
78
+
79
+ @classmethod
80
+ def _from(cls, d: dict) -> RecallResult:
81
+ return cls(
82
+ facts=[Memory._from(x) for x in d.get("facts", [])],
83
+ summaries=d.get("summaries", []) or [],
84
+ documents=d.get("documents", []) or [],
85
+ messages=d.get("messages", []) or [],
86
+ budget=d.get("budget"),
87
+ timed_out_paths=d.get("timed_out_paths", []) or [],
88
+ )
89
+
90
+
91
+ @dataclass
92
+ class ProfileSlot:
93
+ topic: str
94
+ sub_topic: str
95
+ projection: str
96
+ update_hits: int = 1
97
+ source_fact_ids: list[str] = field(default_factory=list) # 可下钻:这条画像由哪些事实支撑
98
+
99
+
100
+ @dataclass
101
+ class Profile:
102
+ slots: list[ProfileSlot] = field(default_factory=list)
103
+ markdown: str | None = None # 叙事版画像(Dream 全量重写)
104
+
105
+ def get(self, topic: str) -> list[ProfileSlot]:
106
+ return [s for s in self.slots if s.topic == topic]
107
+
108
+ def as_prompt(self) -> str:
109
+ return "\n".join(f"- [{s.topic}] {s.projection}" for s in self.slots)
110
+
111
+ @classmethod
112
+ def _from(cls, d: dict) -> Profile:
113
+ return cls(
114
+ slots=[
115
+ ProfileSlot(
116
+ topic=s.get("topic", ""),
117
+ sub_topic=s.get("sub_topic", ""),
118
+ projection=s.get("projection", ""),
119
+ update_hits=s.get("update_hits", 1),
120
+ source_fact_ids=s.get("source_fact_ids", []) or [],
121
+ )
122
+ for s in d.get("slots", [])
123
+ ],
124
+ markdown=d.get("markdown"),
125
+ )
126
+
127
+
128
+ class MemcoreError(Exception):
129
+ def __init__(self, status: int, message: str):
130
+ self.status, self.message = status, message
131
+ super().__init__(f"[{status}] {message}")
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.5
2
+ Name: memcore-sdk
3
+ Version: 0.3.0
4
+ Summary: Python SDK for memcore — a universal memory kernel with explainable, auditable retrieval
5
+ Project-URL: Homepage, https://reglos.ai
6
+ License: Proprietary
7
+ Keywords: agents,llm,memory,rag
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27
10
+ Provides-Extra: all
11
+ Requires-Dist: langchain-core>=0.3; extra == 'all'
12
+ Requires-Dist: llama-index-core>=0.12; extra == 'all'
13
+ Provides-Extra: langchain
14
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
15
+ Provides-Extra: llamaindex
16
+ Requires-Dist: llama-index-core>=0.12; extra == 'llamaindex'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # memcore Python SDK
20
+
21
+ 给你的 AI 应用加一层**可解释、可审计**的长期记忆。
22
+
23
+ ```bash
24
+ pip install memcore-sdk
25
+ ```
26
+
27
+ ## 10 分钟上手
28
+
29
+ ```python
30
+ from memcore import Memcore
31
+
32
+ mc = Memcore(api_key="mk_live_...") # tenant/project 由 key 决定,你不用管
33
+ user = mc.subject("your-user-42") # 绑定记忆主体,后续调用不必再传
34
+
35
+ # 1. 记住
36
+ user.remember("我叫 Richard,住在上海,在做数据库产品,周末喜欢爬山。")
37
+
38
+ # 2. 想起来 —— 直接拼进你的 prompt
39
+ hits = user.recall("他住在哪里")
40
+ system_prompt = "你是一个助手。\n" + hits.as_prompt()
41
+ ```
42
+
43
+ 就这样。抽取、去重、冲突消解、时效管理都在服务端完成。
44
+
45
+ ## 五个动词
46
+
47
+ | 动词 | 用途 |
48
+ | --- | --- |
49
+ | `remember` | 写入:自然语言 / 对话消息 / 直接写事实 / 文档与图片 |
50
+ | `recall` | 检索:多路融合 + 过滤 + **解释** |
51
+ | `profile` / `ask` | 画像:结构化槽位 + 自然语言问答 |
52
+ | `forget` | 遗忘:否决单条 / 删会话 / 全清(带删除回执) |
53
+ | `explain` / `audit` | 溯源:这条记忆怎么来的 + 审计账本 |
54
+
55
+ ## 写入的三种形态
56
+
57
+ ```python
58
+ # 自然语言(最常用)
59
+ user.remember("我下个月要去日本出差")
60
+
61
+ # 对话消息 —— `at` 用于回灌历史,相对时间("三年前")才能被正确解析
62
+ user.remember(messages=[
63
+ {"role": "user", "content": "我三年前搬到杭州", "at": "2023-05-01T10:00:00Z"},
64
+ {"role": "assistant", "content": "杭州挺好的"},
65
+ ])
66
+
67
+ # 你已有自己的抽取,直接写结构化事实
68
+ user.remember(facts=[{"statement": "用户的租约 2027 年 8 月到期",
69
+ "topic": "lifestyle", "confidence": 0.95}])
70
+
71
+ # 文档与图片:PDF / Word / 文本直接解析;图片走 VLM 双路(描述 + OCR)
72
+ user.remember_file("contract.pdf")
73
+ user.remember_file("whiteboard.png")
74
+
75
+ # 需要"写完立刻可查"?
76
+ user.remember("...", sync=True) # 默认异步:落库即返回,抽取在后台
77
+ ```
78
+
79
+ ## 检索与解释
80
+
81
+ ```python
82
+ hits = user.recall("他住在哪里", explain=True)
83
+
84
+ for m in hits:
85
+ print(m.statement, m.is_current) # is_current=False 表示已被更新的事实取代
86
+ print(m.why.matched_paths) # {'vector': {'rank': 1}, 'keyword': {'rank': 3}}
87
+ print(m.why.rrf_contribution) # 各路对最终排名的贡献
88
+ print(m.why.validity) # current / superseded + 有效期
89
+
90
+ print(hits.budget) # 考虑了多少条、返回多少、裁掉多少、用了多少 token
91
+ ```
92
+
93
+ **为什么要 explain**:调试召回质量时你能看到"为什么是这条";合规审查时你能证明答案有据可依。
94
+
95
+ 过滤:
96
+
97
+ ```python
98
+ user.recall("最近的工作", topic="work", since=datetime(2026, 1, 1),
99
+ source="document", min_confidence=0.8, limit=20)
100
+ ```
101
+
102
+ ## 记忆是有时间的
103
+
104
+ 同一件事变了,旧记忆**不会被覆盖**,而是标记失效并保留:
105
+
106
+ ```python
107
+ user.remember("我搬到上海了")
108
+ user.recall("住在哪里") # → 上海(当前有效)
109
+ # 历史仍在:m.is_current == False 的那条记录着"曾经住在杭州"
110
+ ```
111
+
112
+ 这让"我 2023 年住哪"这类问题也答得出来,也让画像的每次变化都有据可查。
113
+
114
+ ## 遗忘的三种语义
115
+
116
+ ```python
117
+ user.forget(memory_id, reason="抽错了") # 作废:查询不再命中,留痕可回溯
118
+ user.forget(session_id="...") # 级联:消息 + 派生事实 + 向量一起清
119
+ receipt = user.forget(everything=True) # 全清:返回可存档的删除回执(合规举证)
120
+ ```
121
+
122
+ ## 溯源与审计
123
+
124
+ ```python
125
+ lineage = user.explain(memory_id)
126
+ # → 原始消息原文、生成记录(模型 + prompt 哈希)、被哪条新事实取代
127
+
128
+ for e in user.audit(since=last_week):
129
+ print(e["at"], e["action"], e["record_id"])
130
+ ```
131
+
132
+ ## 异步
133
+
134
+ 方法名完全一致:
135
+
136
+ ```python
137
+ from memcore import AsyncMemcore
138
+
139
+ async with AsyncMemcore(api_key="...") as mc:
140
+ user = mc.subject("user-42")
141
+ await user.remember("...")
142
+ hits = await user.recall("...")
143
+ ```
144
+
145
+ ## 记忆主体不只是"用户"
146
+
147
+ ```python
148
+ mc.subject("user-42") # 终端用户
149
+ mc.subject("agent-researcher", "agent") # 给 Agent 的记忆
150
+ mc.subject("team-growth", "team") # 团队共享记忆
151
+ ```
152
+
153
+ ## 错误处理
154
+
155
+ ```python
156
+ from memcore import MemcoreError
157
+
158
+ try:
159
+ user.remember("...")
160
+ except MemcoreError as e:
161
+ print(e.status, e.message) # 401 鉴权 / 400 参数 / 404 不存在
162
+ ```
@@ -0,0 +1,9 @@
1
+ memcore/__init__.py,sha256=IHh2Ky2ie_DtCPLt8YZZdS5c2eYEMt_kypSk3kesFy0,585
2
+ memcore/client.py,sha256=c_7kiAvkw_0Cv_x_nqfXtBwvkFbs6_nq2gRNqImqcJE,12175
3
+ memcore/models.py,sha256=p7e98XvKV2Z5EfUTfKbX-J6OM9Jx3t-4bd5VvFUBy0c,4471
4
+ memcore/integrations/__init__.py,sha256=DjOvTXbvPhkuzcm65tA_Al-U65NiVHtMuX4dj5aXf8c,1429
5
+ memcore/integrations/langchain.py,sha256=kS51KJioF_h2ZJ2uDBXMrMz95nxeNFy5IwO2PvNcVsY,6788
6
+ memcore/integrations/llama_index.py,sha256=9evACVho1KcS0pm-0ZAGK68gHNB1J63m3-n8iA5Is5c,7554
7
+ memcore_sdk-0.3.0.dist-info/METADATA,sha256=WOSZv-AHudYoT5BVWsFZfRuJ8a4rF8CZWDfFXGpOe5k,5086
8
+ memcore_sdk-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ memcore_sdk-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any