n1mem 0.1.3__tar.gz

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.
n1mem-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: n1mem
3
+ Version: 0.1.3
4
+ Summary: N1Mem memory API client (BYOK, zero hard dependencies)
5
+ Author: N1Mem (powered by T1Mem engine)
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://www.n1mem.com
8
+ Keywords: memory,llm,rag,agent,n1mem,t1mem
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: async
16
+ Requires-Dist: httpx>=0.24; extra == "async"
17
+
18
+ # n1mem · N1Mem 记忆 API Python SDK
19
+
20
+ > powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
21
+
22
+ ## 安装
23
+
24
+ ```bash
25
+ pip install n1mem # 同步版,零硬依赖
26
+ pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
27
+ ```
28
+
29
+ ## 10 行代码跑通
30
+
31
+ ```python
32
+ from n1mem import N1Mem
33
+
34
+ m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
35
+
36
+ print(m.health()) # 服务健康 + provider 可用性
37
+ m.ingest("我今天换了新工位,在 3 楼靠窗")
38
+ print(m.recall("我坐哪儿"))
39
+ ```
40
+
41
+ ## API
42
+
43
+ | 方法 | 对应端点 | 说明 |
44
+ |---|---|---|
45
+ | 方法 | 对应端点 | 说明 |
46
+ |---|---|---|
47
+ | `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
48
+ | `metrics()` | `GET /metrics` | Prometheus 文本指标 |
49
+ | `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
50
+ | `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
51
+ | `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
52
+
53
+ 异步版 `AsyncN1Mem` 接口完全一致:
54
+
55
+ ```python
56
+ async with AsyncN1Mem(api_key="tk_xxx") as m:
57
+ await m.ingest("…")
58
+ ```
59
+
60
+ ## 错误处理
61
+
62
+ 上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
63
+
64
+ ```python
65
+ from n1mem import N1MemError
66
+ try:
67
+ m.ingest("x")
68
+ except N1MemError as e:
69
+ print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
70
+ ```
71
+
72
+ ## 当前能力边界
73
+
74
+ - `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
75
+ - `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
76
+ - `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
77
+
78
+ > 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
79
+
80
+ ## 与 `t1mem_sdk` 的区别
81
+
82
+ 本目录有两个包,**不要混用**:
83
+
84
+ | 包 | 对接对象 | 用途 |
85
+ |---|---|---|
86
+ | `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
87
+ | `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
88
+
89
+ ## 相关:MCP Server
90
+
91
+ 想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
n1mem-0.1.3/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # t1mem-sdk-py · v0.3.0 发布说明
2
+
3
+ > ⚠ 本目录现有**两个包**,请勿混用:
4
+ > - **`t1mem_sdk`**(本文件描述)—— **内部** SDK,对接本地 t1mem-core API
5
+ > (`http://127.0.0.1:8080`,端点为 `/memories`、`/sessions`、`/stats`)
6
+ > - **`n1mem`** —— **对外** SDK,对接线上 C-2 API(`https://api.n1mem.com`),
7
+ > 文档见 **[README.n1mem.md](README.n1mem.md)**
8
+
9
+ > 发布日期:2026-06-20 · W9.2 阶段 · 负责人:祝融·Vulcan
10
+
11
+ ## 🎯 概览
12
+
13
+ T1Mem Python SDK v0.3.0 — 零依赖同步客户端,10 分钟接入 T1Mem Core API。
14
+
15
+ | 指标 | v0.2.0 | v0.3.0 | 提升 |
16
+ |------|--------|--------|------|
17
+ | API 方法数 | 9 | **11** | +22% |
18
+ | 测试用例 | 6 | **24** | +300% |
19
+ | 测试覆盖 | 端到端 6 类 | **端到端 24 类 + SDK ↔ TS e2e** | 完整 |
20
+ | 鉴权支持 | api_key 参数 | **api_key 参数 + 自动 Bearer** | OK |
21
+ | 错误类型 | T1MemError(status, body) | **同左 + 详细断言** | 增强 |
22
+ | 外部依赖 | 0(纯 urllib) | **0(纯 urllib)** | 保持 |
23
+
24
+ ## 📦 安装
25
+
26
+ ```bash
27
+ pip install t1mem-sdk
28
+ # 或
29
+ pip install git+https://gitee.com/suyupeng/t1mem-sdk-py.git@v0.3.0
30
+ ```
31
+
32
+ ## 🚀 5 分钟上手
33
+
34
+ ```python
35
+ from t1mem_sdk import T1MemClient
36
+
37
+ # 1. 创建客户端
38
+ client = T1MemClient(
39
+ base_url="http://127.0.0.1:8080",
40
+ api_key="tk_xxx", # dev 模式可省略
41
+ timeout=10.0,
42
+ )
43
+
44
+ # 2. 写入一条 memory
45
+ m = client.ingest(
46
+ session_id="user-123",
47
+ role="user",
48
+ content="用户偏好:深色模式 + 中文",
49
+ tags=["preference", "ui"],
50
+ metadata={"source": "onboarding"},
51
+ )
52
+ print(f"saved: {m.id}")
53
+
54
+ # 3. 批量写入
55
+ items = [
56
+ {"session_id": "user-123", "role": "user", "content": "msg-1"},
57
+ {"session_id": "user-123", "role": "assistant", "content": "msg-2"},
58
+ ]
59
+ results = client.ingest_batch(items)
60
+
61
+ # 4. 列出 session
62
+ for m in client.list_session("user-123", limit=50):
63
+ print(f"[{m.role}] {m.content}")
64
+
65
+ # 5. 检索(text/vec/hybrid 三种模式)
66
+ hits = client.search("user-123", "深色模式", mode="hybrid", limit=5)
67
+ for h in hits:
68
+ print(f"hit: {h.id} - {h.content}")
69
+
70
+ # 6. 单条查询
71
+ m = client.get(m.id)
72
+
73
+ # 7. 统计
74
+ print(client.stats())
75
+ ```
76
+
77
+ ## 📚 API 完整列表
78
+
79
+ | 方法 | 用途 | 返回 |
80
+ |------|------|------|
81
+ | `health()` | 健康检查 | `dict` |
82
+ | `whoami()` | 当前 API key 信息 | `dict` |
83
+ | `stats()` | 系统统计 | `Stats` |
84
+ | `ingest(sid, role, content, **)` | 写入单条 | `Memory` |
85
+ | `ingest_batch(items)` | 批量写入 | `list[Memory]` |
86
+ | `get(memory_id)` | 单条查询 | `Memory` |
87
+ | `list_session(sid, limit, offset)` | 列出 session | `list[Memory]` |
88
+ | `search(sid, query, limit, *, mode)` | 检索(text/vec/hybrid) | `list[Memory]` |
89
+
90
+ ## 🧪 测试矩阵(24/24 全过)
91
+
92
+ ```
93
+ tests/test_sdk.py · 6 项 · 原 SDK 集成
94
+ tests/test_w92_sdk.py · 12 项 · W9.2 增强
95
+ tests/test_w10_e2e.py · 6 项 · 跨 SDK e2e
96
+ ```
97
+
98
+ **关键场景**:
99
+ - ✅ 4 种 role(user/assistant/system/tool)全过
100
+ - ✅ 中文 + emoji + 特殊字符 round-trip
101
+ - ✅ 批量 50 条 < 5s
102
+ - ✅ 跨 SDK 互通(Py 写 → TS 读 ID 一致)
103
+ - ✅ 跨 SDK 检索一致(同 query ID 集合相同)
104
+ - ✅ 鉴权/错误(401/404/422 抛 T1MemError)
105
+
106
+ ## 🔄 W9.2 关键变更
107
+
108
+ ### 新增
109
+ - `Memory.org_id` 字段(`v0.2.0` 缺)
110
+ - `search(..., *, mode="hybrid")` 参数(`text`/`vec`/`hybrid`)
111
+ - `test_w92_sdk.py` · 12 项 SDK 单元测试
112
+ - `test_w10_e2e.py` · 6 项跨 SDK 集成测试
113
+
114
+ ### 改进
115
+ - 错误信息更详细(T1MemError.code + T1MemError.body)
116
+ - `list_session` 支持 `offset` 分页
117
+ - `ingest` 支持 `metadata` 合并 `tags`(自动去重)
118
+
119
+ ## 🚧 已知限制
120
+
121
+ - 同步客户端(无 async/await)— 后续可加 aiohttp 版本
122
+ - 不支持流式响应(write/read 都是完整 JSON)
123
+ - retry/backoff 需调用方自行处理
124
+ - 批量上限 1000 条/次(受 API 限制)
125
+
126
+ ## 📅 下一步
127
+
128
+ - W10.1:TypeScript SDK 同等增强(仓颉·Cangjie 已在做)
129
+ - W10.2:发布到 Gitee 私有仓(`t1mem-sdk-py`)
130
+ - W11:Phase 2 闸门验收(Lead)
131
+
132
+ ---
133
+
134
+ Made with ❤️ by 祝融·Vulcan (T1Mem AI 团队 · backend)
@@ -0,0 +1,74 @@
1
+ # n1mem · N1Mem 记忆 API Python SDK
2
+
3
+ > powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install n1mem # 同步版,零硬依赖
9
+ pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
10
+ ```
11
+
12
+ ## 10 行代码跑通
13
+
14
+ ```python
15
+ from n1mem import N1Mem
16
+
17
+ m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
18
+
19
+ print(m.health()) # 服务健康 + provider 可用性
20
+ m.ingest("我今天换了新工位,在 3 楼靠窗")
21
+ print(m.recall("我坐哪儿"))
22
+ ```
23
+
24
+ ## API
25
+
26
+ | 方法 | 对应端点 | 说明 |
27
+ |---|---|---|
28
+ | 方法 | 对应端点 | 说明 |
29
+ |---|---|---|
30
+ | `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
31
+ | `metrics()` | `GET /metrics` | Prometheus 文本指标 |
32
+ | `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
33
+ | `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
34
+ | `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
35
+
36
+ 异步版 `AsyncN1Mem` 接口完全一致:
37
+
38
+ ```python
39
+ async with AsyncN1Mem(api_key="tk_xxx") as m:
40
+ await m.ingest("…")
41
+ ```
42
+
43
+ ## 错误处理
44
+
45
+ 上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
46
+
47
+ ```python
48
+ from n1mem import N1MemError
49
+ try:
50
+ m.ingest("x")
51
+ except N1MemError as e:
52
+ print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
53
+ ```
54
+
55
+ ## 当前能力边界
56
+
57
+ - `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
58
+ - `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
59
+ - `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
60
+
61
+ > 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
62
+
63
+ ## 与 `t1mem_sdk` 的区别
64
+
65
+ 本目录有两个包,**不要混用**:
66
+
67
+ | 包 | 对接对象 | 用途 |
68
+ |---|---|---|
69
+ | `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
70
+ | `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
71
+
72
+ ## 相关:MCP Server
73
+
74
+ 想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
@@ -0,0 +1,21 @@
1
+ """N1Mem —— 记忆 API 的 Python SDK(BYOK,零硬依赖)。
2
+
3
+ 快速开始::
4
+
5
+ from n1mem import N1Mem
6
+ m = N1Mem(api_key="tk_xxx")
7
+ m.ingest("我今天换了新工位,在 3 楼靠窗")
8
+ m.recall("我坐哪儿")
9
+
10
+ ⚠ 当前 C-2 处于 Phase 0:写入尚未落真实存储层,召回不到刚写入的内容。
11
+ 存储层(Phase 1)接入后本说明会更新。
12
+ """
13
+ from .client import (
14
+ DEFAULT_BASE_URL,
15
+ AsyncN1Mem,
16
+ N1Mem,
17
+ N1MemError,
18
+ )
19
+
20
+ __version__ = "0.1.2"
21
+ __all__ = ["N1Mem", "AsyncN1Mem", "N1MemError", "DEFAULT_BASE_URL", "__version__"]
@@ -0,0 +1,244 @@
1
+ """N1Mem 对外 Python SDK —— 对接线上 C-2 API。
2
+
3
+ 设计原则:
4
+ * **零硬依赖**:同步客户端只用标准库 urllib,`pip install n1mem` 不拖任何包。
5
+ * **异步可选**:`pip install n1mem[async]` 才装 httpx;不装也能用同步版。
6
+ * **BYOK**:api_key 由调用方提供,SDK 不存储、不上传任何凭据。
7
+ * **诚实**:线上暂未提供的能力(ask/update/forget/list)**明确抛 NotImplementedError**,
8
+ 绝不伪装成"已支持"。这是本项目「可信第一」定位的底线。
9
+
10
+ C-2 当前已提供:/health、/metrics、/v1/ingest(持久化 + 向量化)、/v1/recall
11
+ (hybrid 检索 + 基于命中记忆接地的 answer)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json as _json
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ from typing import Any
20
+
21
+ __all__ = ["N1Mem", "AsyncN1Mem", "N1MemError", "DEFAULT_BASE_URL"]
22
+
23
+ # 默认指向官方品牌域名 api.n1mem.com(已绑定到 live C-2 公网端点:阿里云 FC 香港,免备案)。
24
+ # 该域名是项目自有的稳定入口,优于 FC 自动生成的默认域名(重建函数可能变更)。
25
+ DEFAULT_BASE_URL = "https://api.n1mem.com"
26
+
27
+ # 线上 C-2 当前具备的业务端点(Phase 0)
28
+ _SUPPORTED = ("/health", "/metrics", "/v1/ingest", "/v1/recall")
29
+
30
+ # 规划中、线上尚未提供的能力(C-2 Phase 1 待接入)
31
+ _NOT_READY = {
32
+ "ask": "需 C-2 提供 /v1/ask(规划中)",
33
+ "update": "需 C-2 提供 /v1/memories/{id} 更新(规划中,且依赖存储层)",
34
+ "forget": "需 C-2 提供 /v1/memories/{id} 删除(规划中,且依赖存储层)",
35
+ "list": "需 C-2 提供 /v1/memories 列表(规划中,且依赖存储层)",
36
+ }
37
+
38
+
39
+ class N1MemError(Exception):
40
+ """API 返回错误。"""
41
+
42
+ def __init__(self, status: int, message: str, endpoint: str = ""):
43
+ super().__init__(f"[{status}] {message}" + (f" ({endpoint})" if endpoint else ""))
44
+ self.status = status
45
+ self.message = message
46
+ self.endpoint = endpoint
47
+
48
+
49
+ class _Base:
50
+ def __init__(self, api_key: str | None = None,
51
+ base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0):
52
+ self.api_key = api_key
53
+ self.base_url = base_url.rstrip("/")
54
+ self.timeout = timeout
55
+
56
+ # ---- 内部:请求构造 ----
57
+ def _url(self, path: str, params: dict | None = None) -> str:
58
+ u = self.base_url + path
59
+ if params:
60
+ u += "?" + urllib.parse.urlencode(params)
61
+ return u
62
+
63
+ def _headers(self, with_body: bool = False) -> dict:
64
+ h = {"Accept": "application/json"}
65
+ if with_body:
66
+ h["Content-Type"] = "application/json"
67
+ if self.api_key:
68
+ h["Authorization"] = f"Bearer {self.api_key}"
69
+ return h
70
+
71
+ @staticmethod
72
+ def _raise_from(exc: urllib.error.HTTPError, path: str):
73
+ try:
74
+ body = exc.read().decode("utf-8", "replace")
75
+ except Exception:
76
+ body = ""
77
+ raise N1MemError(exc.code, body or exc.reason, path) from exc
78
+
79
+
80
+ class N1Mem(_Base):
81
+ """同步客户端。
82
+
83
+ 用法::
84
+
85
+ from n1mem import N1Mem
86
+ m = N1Mem(api_key="tk_xxx") # 或环境变量 N1MEM_API_KEY
87
+ m.health()
88
+ m.ingest("我今天换了新工位")
89
+ m.recall("我坐哪儿")
90
+ """
91
+
92
+ def __init__(self, api_key: str | None = None, **kw):
93
+ import os
94
+ super().__init__(api_key or os.environ.get("N1MEM_API_KEY"), **kw)
95
+
96
+ # ---- 低层 ----
97
+ def _request(self, method: str, path: str, body: dict | None = None,
98
+ params: dict | None = None) -> Any:
99
+ data = _json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
100
+ req = urllib.request.Request(
101
+ self._url(path, params), data=data, method=method,
102
+ headers=self._headers(with_body=data is not None))
103
+ try:
104
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
105
+ raw = r.read().decode("utf-8", "replace")
106
+ return _json.loads(raw) if raw else {}
107
+ except urllib.error.HTTPError as e:
108
+ self._raise_from(e, path)
109
+ except urllib.error.URLError as e:
110
+ raise N1MemError(0, f"connection error: {e.reason}", path) from e
111
+
112
+ # ---- 已支持 ----
113
+ def health(self) -> dict:
114
+ """服务健康与 provider 可用性。无需鉴权。"""
115
+ return self._request("GET", "/health")
116
+
117
+ def metrics(self) -> str:
118
+ """Prometheus 文本指标。无需鉴权。"""
119
+ req = urllib.request.Request(self._url("/metrics"),
120
+ headers=self._headers())
121
+ try:
122
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
123
+ return r.read().decode("utf-8", "replace")
124
+ except urllib.error.HTTPError as e:
125
+ self._raise_from(e, "/metrics")
126
+ except urllib.error.URLError as e:
127
+ raise N1MemError(0, f"connection error: {e.reason}", "/metrics") from e
128
+
129
+ def ingest(self, text: str, *, purpose: str = "recall") -> dict:
130
+ """写入一段记忆。
131
+
132
+ 内容会持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`。
133
+ 写入后即可被 `recall()` 召回。
134
+ """
135
+ return self._request("POST", "/v1/ingest", {"text": text, "purpose": purpose})
136
+
137
+ def recall(self, prompt: str, *, purpose: str = "recall", mode: str | None = None) -> dict:
138
+ """按提示召回记忆。
139
+
140
+ 返回体含 `answer`(已基于命中记忆接地的生成回答)、`retrieved`(命中的明文记忆
141
+ 列表),以及 `recall_mode`(keyword|semantic|hybrid)等路由信息。
142
+
143
+ mode=None 时用服务端默认(hybrid:关键词 + 向量 RRF 融合,可召回改写问法);
144
+ 显式传 "keyword" 则回到纯关键词口径(用于回归对比)。
145
+ """
146
+ body = {"prompt": prompt, "purpose": purpose}
147
+ if mode:
148
+ body["mode"] = mode
149
+ return self._request("POST", "/v1/recall", body)
150
+
151
+ # ---- 规划中(明确报错,不伪装)----
152
+ def ask(self, *a, **kw):
153
+ raise NotImplementedError(_NOT_READY["ask"])
154
+
155
+ def update(self, *a, **kw):
156
+ raise NotImplementedError(_NOT_READY["update"])
157
+
158
+ def forget(self, *a, **kw):
159
+ raise NotImplementedError(_NOT_READY["forget"])
160
+
161
+ def list(self, *a, **kw):
162
+ raise NotImplementedError(_NOT_READY["list"])
163
+
164
+ def __repr__(self) -> str:
165
+ key = (self.api_key[:6] + "…") if self.api_key else "None"
166
+ return f"N1Mem(base_url={self.base_url!r}, api_key={key!r})"
167
+
168
+
169
+ class AsyncN1Mem(_Base):
170
+ """异步客户端(需 `pip install n1mem[async]`)。
171
+
172
+ 用法::
173
+
174
+ async with AsyncN1Mem(api_key="tk_xxx") as m:
175
+ await m.ingest("…")
176
+ """
177
+
178
+ def __init__(self, api_key: str | None = None, **kw):
179
+ import os
180
+ super().__init__(api_key or os.environ.get("N1MEM_API_KEY"), **kw)
181
+ try:
182
+ import httpx # noqa: F401
183
+ except ImportError as e: # pragma: no cover
184
+ raise ImportError(
185
+ "AsyncN1Mem 需要 httpx:`pip install n1mem[async]`") from e
186
+ self._client = None
187
+
188
+ async def __aenter__(self):
189
+ import httpx
190
+ self._client = httpx.AsyncClient(
191
+ base_url=self.base_url, timeout=self.timeout,
192
+ headers=self._headers(with_body=True))
193
+ return self
194
+
195
+ async def __aexit__(self, *exc):
196
+ if self._client:
197
+ await self._client.aclose()
198
+ self._client = None
199
+
200
+ async def _arequest(self, method: str, path: str,
201
+ body: dict | None = None) -> Any:
202
+ import httpx
203
+ own = self._client is None
204
+ if own:
205
+ await self.__aenter__()
206
+ try:
207
+ r = await self._client.request(method, path, json=body)
208
+ if r.status_code >= 400:
209
+ raise N1MemError(r.status_code, r.text[:400], path)
210
+ ct = r.headers.get("content-type", "")
211
+ return r.json() if "json" in ct else r.text
212
+ finally:
213
+ if own:
214
+ await self.__aexit__()
215
+
216
+ async def health(self) -> dict:
217
+ return await self._arequest("GET", "/health")
218
+
219
+ async def metrics(self) -> str:
220
+ return await self._arequest("GET", "/metrics")
221
+
222
+ async def ingest(self, text: str, *, purpose: str = "recall") -> dict:
223
+ return await self._arequest("POST", "/v1/ingest",
224
+ {"text": text, "purpose": purpose})
225
+
226
+ async def recall(self, prompt: str, *, purpose: str = "recall",
227
+ mode: str | None = None) -> dict:
228
+ body = {"prompt": prompt, "purpose": purpose}
229
+ if mode:
230
+ body["mode"] = mode
231
+ return await self._arequest("POST", "/v1/recall", body)
232
+
233
+ # ---- 规划中(明确报错,不伪装;与同步版口径一致)----
234
+ async def ask(self, *a, **kw):
235
+ raise NotImplementedError(_NOT_READY["ask"])
236
+
237
+ async def update(self, *a, **kw):
238
+ raise NotImplementedError(_NOT_READY["update"])
239
+
240
+ async def forget(self, *a, **kw):
241
+ raise NotImplementedError(_NOT_READY["forget"])
242
+
243
+ async def list(self, *a, **kw):
244
+ raise NotImplementedError(_NOT_READY["list"])
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: n1mem
3
+ Version: 0.1.3
4
+ Summary: N1Mem memory API client (BYOK, zero hard dependencies)
5
+ Author: N1Mem (powered by T1Mem engine)
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://www.n1mem.com
8
+ Keywords: memory,llm,rag,agent,n1mem,t1mem
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: async
16
+ Requires-Dist: httpx>=0.24; extra == "async"
17
+
18
+ # n1mem · N1Mem 记忆 API Python SDK
19
+
20
+ > powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
21
+
22
+ ## 安装
23
+
24
+ ```bash
25
+ pip install n1mem # 同步版,零硬依赖
26
+ pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
27
+ ```
28
+
29
+ ## 10 行代码跑通
30
+
31
+ ```python
32
+ from n1mem import N1Mem
33
+
34
+ m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
35
+
36
+ print(m.health()) # 服务健康 + provider 可用性
37
+ m.ingest("我今天换了新工位,在 3 楼靠窗")
38
+ print(m.recall("我坐哪儿"))
39
+ ```
40
+
41
+ ## API
42
+
43
+ | 方法 | 对应端点 | 说明 |
44
+ |---|---|---|
45
+ | 方法 | 对应端点 | 说明 |
46
+ |---|---|---|
47
+ | `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
48
+ | `metrics()` | `GET /metrics` | Prometheus 文本指标 |
49
+ | `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
50
+ | `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
51
+ | `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
52
+
53
+ 异步版 `AsyncN1Mem` 接口完全一致:
54
+
55
+ ```python
56
+ async with AsyncN1Mem(api_key="tk_xxx") as m:
57
+ await m.ingest("…")
58
+ ```
59
+
60
+ ## 错误处理
61
+
62
+ 上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
63
+
64
+ ```python
65
+ from n1mem import N1MemError
66
+ try:
67
+ m.ingest("x")
68
+ except N1MemError as e:
69
+ print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
70
+ ```
71
+
72
+ ## 当前能力边界
73
+
74
+ - `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
75
+ - `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
76
+ - `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
77
+
78
+ > 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
79
+
80
+ ## 与 `t1mem_sdk` 的区别
81
+
82
+ 本目录有两个包,**不要混用**:
83
+
84
+ | 包 | 对接对象 | 用途 |
85
+ |---|---|---|
86
+ | `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
87
+ | `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
88
+
89
+ ## 相关:MCP Server
90
+
91
+ 想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
@@ -0,0 +1,14 @@
1
+ README.md
2
+ README.n1mem.md
3
+ pyproject.toml
4
+ n1mem/__init__.py
5
+ n1mem/client.py
6
+ n1mem.egg-info/PKG-INFO
7
+ n1mem.egg-info/SOURCES.txt
8
+ n1mem.egg-info/dependency_links.txt
9
+ n1mem.egg-info/requires.txt
10
+ n1mem.egg-info/top_level.txt
11
+ tests/test_n1mem_cloud.py
12
+ tests/test_sdk.py
13
+ tests/test_w10_e2e.py
14
+ tests/test_w92_sdk.py
@@ -0,0 +1,3 @@
1
+
2
+ [async]
3
+ httpx>=0.24
@@ -0,0 +1 @@
1
+ n1mem
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "n1mem"
7
+ version = "0.1.3"
8
+ description = "N1Mem memory API client (BYOK, zero hard dependencies)"
9
+ readme = "README.n1mem.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "N1Mem (powered by T1Mem engine)" }]
13
+ keywords = ["memory", "llm", "rag", "agent", "n1mem", "t1mem"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ async = ["httpx>=0.24"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://www.n1mem.com"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["n1mem*"]
30
+
31
+ # 说明:本文件只打包对外的 `n1mem` 包。
32
+ # 同目录下的 `t1mem_sdk/` 是**内部** SDK(对接本地 t1mem-core API),
33
+ # 接口与线上 C-2 不同套,不对外发布,故在此排除。
n1mem-0.1.3/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,115 @@
1
+ """n1mem 对外 SDK 单测(全部 mock,零网络、零 API 花费)。
2
+
3
+ 覆盖:
4
+ 1. 默认端点与鉴权头正确
5
+ 2. ingest / recall 请求体字段与线上 C-2 契约一致
6
+ 3. 上游 401 / 429 正确转成 N1MemError
7
+ 4. 未就绪能力(ask/update/forget/list)明确 NotImplementedError —— 不伪装支持
8
+ 5. 无 api_key 时不带 Authorization 头
9
+ """
10
+ import io
11
+ import json
12
+ import urllib.error
13
+
14
+ import pytest
15
+
16
+ from n1mem import DEFAULT_BASE_URL, N1Mem, N1MemError
17
+
18
+
19
+ class _Resp(io.BytesIO):
20
+ def __init__(self, payload, status=200):
21
+ raw = payload if isinstance(payload, str) else json.dumps(payload)
22
+ super().__init__(raw.encode("utf-8"))
23
+ self.status = status
24
+
25
+ def __enter__(self):
26
+ return self
27
+
28
+ def __exit__(self, *a):
29
+ return False
30
+
31
+
32
+ def _capture(monkeypatch):
33
+ """拦截 urlopen,返回 (记录的请求, 预设响应设置器)。"""
34
+ box = {}
35
+
36
+ def fake_urlopen(req, timeout=None, **kw):
37
+ box["req"] = req
38
+ box["timeout"] = timeout
39
+ return box.get("resp", _Resp({"ok": True}))
40
+
41
+ monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
42
+ return box
43
+
44
+
45
+ def test_default_base_url_is_production():
46
+ m = N1Mem(api_key="tk_x")
47
+ assert m.base_url == DEFAULT_BASE_URL == "https://api.n1mem.com"
48
+
49
+
50
+ def test_auth_header_present(monkeypatch):
51
+ box = _capture(monkeypatch)
52
+ N1Mem(api_key="tk_abc").health()
53
+ assert box["req"].get_header("Authorization") == "Bearer tk_abc"
54
+
55
+
56
+ def test_no_key_no_auth_header(monkeypatch):
57
+ box = _capture(monkeypatch)
58
+ N1Mem(api_key=None).health()
59
+ assert box["req"].get_header("Authorization") is None
60
+
61
+
62
+ def test_ingest_body_matches_api_contract(monkeypatch):
63
+ box = _capture(monkeypatch)
64
+ N1Mem(api_key="tk").ingest("我今天换了新工位")
65
+ req = box["req"]
66
+ assert req.get_method() == "POST"
67
+ assert req.full_url == DEFAULT_BASE_URL + "/v1/ingest"
68
+ body = json.loads(req.data.decode("utf-8"))
69
+ assert body["text"] == "我今天换了新工位"
70
+ assert body["purpose"] == "recall"
71
+
72
+
73
+ def test_recall_body_matches_api_contract(monkeypatch):
74
+ box = _capture(monkeypatch)
75
+ N1Mem(api_key="tk").recall("我坐哪儿")
76
+ req = box["req"]
77
+ assert req.full_url == DEFAULT_BASE_URL + "/v1/recall"
78
+ body = json.loads(req.data.decode("utf-8"))
79
+ assert body["prompt"] == "我坐哪儿"
80
+
81
+
82
+ def test_health_returns_parsed_json(monkeypatch):
83
+ box = _capture(monkeypatch)
84
+ box["resp"] = _Resp({"status": "ok", "region": "cn-hongkong"})
85
+ out = N1Mem(api_key="tk").health()
86
+ assert out["status"] == "ok"
87
+ assert out["region"] == "cn-hongkong"
88
+
89
+
90
+ @pytest.mark.parametrize("code", [401, 429])
91
+ def test_http_error_becomes_n1mem_error(code, monkeypatch):
92
+ def boom(req, timeout=None, **kw):
93
+ raise urllib.error.HTTPError(
94
+ DEFAULT_BASE_URL + "/v1/ingest", code, "err", {},
95
+ io.BytesIO(b'{"detail":"nope"}'))
96
+
97
+ monkeypatch.setattr("urllib.request.urlopen", boom)
98
+ with pytest.raises(N1MemError) as ei:
99
+ N1Mem(api_key="tk").ingest("x")
100
+ assert ei.value.status == code
101
+ assert "nope" in ei.value.message
102
+
103
+
104
+ @pytest.mark.parametrize("name", ["ask", "update", "forget", "list"])
105
+ def test_not_ready_capabilities_raise_clearly(name):
106
+ """关键:未就绪的能力必须明确报错,绝不能静默伪装成可用。"""
107
+ with pytest.raises(NotImplementedError) as ei:
108
+ getattr(N1Mem(api_key="tk"), name)("x")
109
+ assert "C-2" in str(ei.value) or "规划中" in str(ei.value)
110
+
111
+
112
+ def test_repr_hides_key():
113
+ r = repr(N1Mem(api_key="tk_secret_value_123"))
114
+ assert "secret_value_123" not in r
115
+ assert "tk_sec" in r
@@ -0,0 +1,75 @@
1
+ """T1Mem SDK tests (integration with running server)."""
2
+ import os
3
+ import sys
4
+ import uuid
5
+
6
+ import pytest
7
+
8
+ # Ensure SDK is on path
9
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+ sys.path.insert(0, ROOT)
11
+
12
+ from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
13
+
14
+
15
+ BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
16
+
17
+
18
+ @pytest.fixture
19
+ def client():
20
+ return T1MemClient(BASE_URL)
21
+
22
+
23
+ @pytest.fixture
24
+ def session_id():
25
+ return f"test-{uuid.uuid4().hex[:8]}"
26
+
27
+
28
+ def test_health(client):
29
+ h = client.health()
30
+ assert h["status"] == "ok"
31
+
32
+
33
+ def test_ingest_and_get(client, session_id):
34
+ m = client.ingest(session_id, "user", "SDK test message", tags=["pytest"])
35
+ assert m.session_id == session_id
36
+ assert m.content == "SDK test message"
37
+ assert "pytest" in m.metadata.get("tags", [])
38
+
39
+ fetched = client.get(m.id)
40
+ assert fetched.id == m.id
41
+
42
+
43
+ def test_list_and_search(client, session_id):
44
+ client.ingest(session_id, "user", "alpha")
45
+ client.ingest(session_id, "assistant", "beta")
46
+ client.ingest(session_id, "user", "T1Mem rocks")
47
+
48
+ items = client.list_session(session_id)
49
+ assert len(items) == 3
50
+
51
+ hits = client.search(session_id, "T1Mem")
52
+ assert len(hits) == 1
53
+ assert "T1Mem" in hits[0].content
54
+
55
+
56
+ def test_stats(client):
57
+ s = client.stats()
58
+ assert s.total >= 0
59
+ assert s.last_24h >= 0
60
+
61
+
62
+ def test_404_raises(client):
63
+ with pytest.raises(T1MemError) as exc:
64
+ client.get("nonexistent-" + uuid.uuid4().hex)
65
+ assert exc.value.status == 404
66
+
67
+
68
+ def test_batch_ingest(client, session_id):
69
+ items = [
70
+ {"session_id": session_id, "role": "user", "content": f"msg-{i}"}
71
+ for i in range(5)
72
+ ]
73
+ results = client.ingest_batch(items)
74
+ assert len(results) == 5
75
+ assert all(r.session_id == session_id for r in results)
@@ -0,0 +1,205 @@
1
+ """W10: SDK e2e 测试 — Python SDK ↔ TypeScript SDK 数据互通
2
+
3
+ 场景:
4
+ 1. Python SDK 写入 → TypeScript SDK 读取(验证序列化一致)
5
+ 2. TypeScript SDK 写入 → Python SDK 读取
6
+ 3. 两端 search 行为一致
7
+ 4. 跨 SDK 鉴权一致性
8
+ """
9
+ import os
10
+ import sys
11
+ import time
12
+ import uuid
13
+ import json
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+ sys.path.insert(0, ROOT)
21
+
22
+ from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
23
+
24
+ BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
25
+ TS_SDK_DIR = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))).parent / "t1mem-sdk-ts"
26
+
27
+
28
+ @pytest.fixture
29
+ def py_client():
30
+ return T1MemClient(BASE_URL)
31
+
32
+
33
+ @pytest.fixture
34
+ def shared_session():
35
+ """e2e 共用 session_id"""
36
+ return f"e2e-{uuid.uuid4().hex[:8]}"
37
+
38
+
39
+ def _run_ts(args: list[str], env_extra: dict | None = None) -> tuple[int, str, str]:
40
+ """跑一段 TS SDK 测试脚本,返回 (rc, stdout, stderr)"""
41
+ env = os.environ.copy()
42
+ env["T1MEM_URL"] = BASE_URL
43
+ if env_extra:
44
+ env.update(env_extra)
45
+ # 用绝对路径避开 Windows subprocess 找不到 npx
46
+ npx_bin = r"C:\Users\Administrator\.workbuddy\binaries\node\versions\22.22.2\npx.cmd"
47
+ r = subprocess.run(
48
+ [npx_bin, "tsx"] + args,
49
+ cwd=str(TS_SDK_DIR),
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=60,
53
+ env=env,
54
+ shell=True,
55
+ )
56
+ return r.returncode, r.stdout, r.stderr
57
+
58
+
59
+ def test_w10_01_py_ingest_ts_read(py_client, shared_session):
60
+ """W10.1: Python 写入 → TS 读取(通过临时 TS 脚本调 list)"""
61
+ py_client.ingest(shared_session, "user", "Python wrote this: T1Mem rocks")
62
+ py_client.ingest(shared_session, "assistant", "TS SDK should see this")
63
+ time.sleep(0.3)
64
+
65
+ # 让 TS 跑一个临时脚本去 list 这个 session
66
+ script = f"""
67
+ import {{ T1MemClient }} from "../src/index.ts";
68
+ const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
69
+ const list = await c.listSession("{shared_session}", {{ limit: 10 }});
70
+ console.log(JSON.stringify({{ count: list.length, items: list.map(m => ({{ content: m.content, role: m.role, orgId: m.orgId }})) }}));
71
+ """
72
+ script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_list.ts"
73
+ script_path.write_text(script)
74
+ try:
75
+ rc, out, err = _run_ts(["tests/_tmp_e2e_list.ts"])
76
+ assert rc == 0, f"TS script failed: {err}"
77
+ data = json.loads(out.strip().splitlines()[-1])
78
+ assert data["count"] == 2
79
+ assert data["items"][0]["orgId"] == "default"
80
+ # 至少 1 条含 T1Mem(顺序可能因 timestamp 翻转)
81
+ assert any("T1Mem" in it["content"] for it in data["items"])
82
+ finally:
83
+ if script_path.exists():
84
+ script_path.unlink()
85
+
86
+
87
+ def test_w10_02_ts_ingest_py_read(py_client, shared_session):
88
+ """W10.2: TS 写入 → Python 读取"""
89
+ script = f"""
90
+ import {{ T1MemClient }} from "../src/index.ts";
91
+ const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
92
+ const m1 = await c.ingest({{ sessionId: "{shared_session}", role: "user", content: "TS wrote: T1Mem Python SDK", tags: ["from-ts"] }});
93
+ const m2 = await c.ingest({{ sessionId: "{shared_session}", role: "assistant", content: "Py SDK should see this" }});
94
+ console.log(JSON.stringify({{ ids: [m1.id, m2.id], tags: m1.metadata?.tags }}));
95
+ """
96
+ script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_ingest.ts"
97
+ script_path.write_text(script)
98
+ try:
99
+ rc, out, err = _run_ts(["tests/_tmp_e2e_ingest.ts"])
100
+ assert rc == 0, f"TS script failed: {err}"
101
+ data = json.loads(out.strip().splitlines()[-1])
102
+ ts_ids = set(data["ids"])
103
+ assert "from-ts" in (data.get("tags") or [])
104
+ finally:
105
+ if script_path.exists():
106
+ script_path.unlink()
107
+
108
+ time.sleep(0.3)
109
+ # Python 读
110
+ items = py_client.list_session(shared_session)
111
+ assert len(items) == 2
112
+ py_ids = {m.id for m in items}
113
+ # IDs 必须完全一致
114
+ assert py_ids == ts_ids, f"ID mismatch: py={py_ids}, ts={ts_ids}"
115
+
116
+
117
+ def test_w10_03_search_consistency(py_client, shared_session):
118
+ """W10.3: Python 和 TS 搜同一关键词,结果 ID 集合一致"""
119
+ # Py 写 3 条
120
+ py_client.ingest(shared_session, "user", "T1Mem 提供 Python 和 TypeScript SDK")
121
+ py_client.ingest(shared_session, "assistant", "T1Mem 是 Top-1 AI 记忆中间件")
122
+ py_client.ingest(shared_session, "user", "普通的文档")
123
+ time.sleep(0.5)
124
+
125
+ # Py 搜
126
+ py_hits = py_client.search(shared_session, "T1Mem", limit=10)
127
+ py_ids = sorted(m.id for m in py_hits)
128
+
129
+ # TS 搜
130
+ script = f"""
131
+ import {{ T1MemClient }} from "../src/index.ts";
132
+ const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
133
+ const hits = await c.search("{shared_session}", "T1Mem", {{ limit: 10 }});
134
+ console.log(JSON.stringify(hits.map(h => h.id).sort()));
135
+ """
136
+ script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_search.ts"
137
+ script_path.write_text(script)
138
+ try:
139
+ rc, out, err = _run_ts(["tests/_tmp_e2e_search.ts"])
140
+ assert rc == 0, f"TS script failed: {err}"
141
+ ts_ids = sorted(json.loads(out.strip().splitlines()[-1]))
142
+ # 两个 SDK 搜到的 ID 集合必须一致
143
+ assert py_ids == ts_ids, f"Py={py_ids}, TS={ts_ids}"
144
+ finally:
145
+ if script_path.exists():
146
+ script_path.unlink()
147
+
148
+
149
+ def test_w10_04_chinese_round_trip(py_client, shared_session):
150
+ """W10.4: 中文 + emoji 跨 SDK 互通"""
151
+ content = "T1Mem 测试中文 + emoji 🎉 + 特殊字符 !@#$%"
152
+ py_client.ingest(shared_session, "user", content)
153
+ time.sleep(0.3)
154
+
155
+ # Py 读
156
+ items = py_client.list_session(shared_session)
157
+ assert len(items) == 1
158
+ assert items[0].content == content
159
+
160
+ # TS 读
161
+ script = f"""
162
+ import {{ T1MemClient }} from "../src/index.ts";
163
+ const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
164
+ const items = await c.listSession("{shared_session}", {{ limit: 5 }});
165
+ console.log(JSON.stringify({{ count: items.length, content: items[0]?.content }}));
166
+ """
167
+ script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_cn.ts"
168
+ script_path.write_text(script)
169
+ try:
170
+ rc, out, err = _run_ts(["tests/_tmp_e2e_cn.ts"])
171
+ assert rc == 0, f"TS script failed: {err}"
172
+ data = json.loads(out.strip().splitlines()[-1])
173
+ assert data["count"] == 1
174
+ assert data["content"] == content
175
+ finally:
176
+ if script_path.exists():
177
+ script_path.unlink()
178
+
179
+
180
+ def test_w10_05_batch_50_items(py_client, shared_session):
181
+ """W10.5: 批量 50 条性能(< 5s)"""
182
+ items = [
183
+ {"session_id": shared_session, "role": "user", "content": f"batch-e2e-{i:03d}"}
184
+ for i in range(50)
185
+ ]
186
+ t0 = time.time()
187
+ results = py_client.ingest_batch(items)
188
+ elapsed = time.time() - t0
189
+ assert len(results) == 50
190
+ assert elapsed < 5.0, f"batch took {elapsed:.2f}s, expected <5s"
191
+
192
+
193
+ def test_w10_06_ts_test_files_collect(shared_session):
194
+ """W10.6: 验证 TS SDK 自带 3 个测试文件全过(ingest/recall/search)"""
195
+ rc, out, err = _run_ts([
196
+ "--test", "tests/ingest.test.mjs",
197
+ "--test", "tests/recall.test.mjs",
198
+ "--test", "tests/search.test.mjs",
199
+ ])
200
+ # summary 末尾
201
+ lines = out.strip().splitlines()
202
+ last_summary = [l for l in lines if l.startswith("# ")]
203
+ summary_text = "\n".join(last_summary)
204
+ assert "# pass" in summary_text
205
+ assert "# fail 0" in summary_text, f"TS tests failed:\n{out[-2000:]}"
@@ -0,0 +1,164 @@
1
+ """W9.2: Python SDK 增强测试(祝融主)
2
+
3
+ 覆盖:tags / metadata / 4 种 role / 错误处理 / 中文 / batch
4
+ """
5
+ import os
6
+ import sys
7
+ import time
8
+ import uuid
9
+
10
+ import pytest
11
+
12
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+ sys.path.insert(0, ROOT)
14
+
15
+ from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
16
+
17
+ BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
18
+
19
+
20
+ @pytest.fixture
21
+ def client():
22
+ return T1MemClient(BASE_URL)
23
+
24
+
25
+ @pytest.fixture
26
+ def session_id():
27
+ return f"py-sdk-w92-{uuid.uuid4().hex[:8]}"
28
+
29
+
30
+ def test_w92_01_ingest_basic(client, session_id):
31
+ """W9.2.1: 基本写入 + tags 合并"""
32
+ m = client.ingest(
33
+ session_id, "user", "T1Mem 是 Top-1 AI 记忆中间件",
34
+ tags=["w9.2", "test"],
35
+ )
36
+ assert m.session_id == session_id
37
+ assert m.content == "T1Mem 是 Top-1 AI 记忆中间件"
38
+ assert m.role == "user"
39
+ assert m.id # 必有 id
40
+ assert m.org_id == "default"
41
+ assert "w9.2" in m.metadata.get("tags", [])
42
+ assert m.created_at > 0
43
+
44
+
45
+ def test_w92_02_metadata_and_tags_merge(client, session_id):
46
+ """W9.2.2: 用户传 metadata + tags,SDK 应合并"""
47
+ m = client.ingest(
48
+ session_id, "assistant", "测试 metadata 合并",
49
+ metadata={"source": "w9.2-test", "priority": "high"},
50
+ tags=["meta-merge"],
51
+ )
52
+ assert m.metadata["source"] == "w9.2-test"
53
+ assert m.metadata["priority"] == "high"
54
+ assert "meta-merge" in m.metadata.get("tags", [])
55
+
56
+
57
+ def test_w92_03_four_roles(client, session_id):
58
+ """W9.2.3: user/assistant/system/tool 4 种 role 都接受"""
59
+ for role in ["user", "assistant", "system", "tool"]:
60
+ m = client.ingest(session_id, role, f"{role} role test")
61
+ assert m.role == role
62
+ items = client.list_session(session_id)
63
+ assert len(items) == 4
64
+
65
+
66
+ def test_w92_04_invalid_role_raises(client, session_id):
67
+ """W9.2.4: 非法 role 抛 T1MemError(422)"""
68
+ with pytest.raises(T1MemError) as exc:
69
+ client.ingest(session_id, "hacker", "should fail")
70
+ assert exc.value.status == 422
71
+
72
+
73
+ def test_w92_05_empty_content_raises(client, session_id):
74
+ """W9.2.5: 空 content 抛 T1MemError(422)"""
75
+ with pytest.raises(T1MemError) as exc:
76
+ client.ingest(session_id, "user", "")
77
+ assert exc.value.status == 422
78
+
79
+
80
+ def test_w92_06_batch_ingest(client, session_id):
81
+ """W9.2.6: 批量 10 条"""
82
+ items = [
83
+ {"session_id": session_id, "role": "user", "content": f"batch-{i}"}
84
+ for i in range(10)
85
+ ]
86
+ results = client.ingest_batch(items)
87
+ assert len(results) == 10
88
+ for i, r in enumerate(results):
89
+ assert r.content == f"batch-{i}"
90
+ assert r.session_id == session_id
91
+
92
+
93
+ def test_w92_07_chinese_search(client, session_id):
94
+ """W9.2.7: 中文写入 + 中文检索"""
95
+ client.ingest(session_id, "user", "T1Mem 提供 Python 和 TypeScript SDK")
96
+ client.ingest(session_id, "assistant", "项目预算是 50 万人民币")
97
+ time.sleep(0.3)
98
+ # 搜中文
99
+ hits = client.search(session_id, "项目", limit=5)
100
+ assert len(hits) >= 1
101
+ assert any("项目" in h.content for h in hits)
102
+
103
+
104
+ def test_w92_08_search_modes(client, session_id):
105
+ """W9.2.8: search 模式切换 text/vec/hybrid"""
106
+ client.ingest(session_id, "user", "T1Mem 提供 Python SDK")
107
+ time.sleep(0.3)
108
+ # text 模式
109
+ hits_text = client.search(session_id, "T1Mem", limit=5, mode="text")
110
+ assert len(hits_text) >= 1
111
+ # hybrid 模式
112
+ hits_h = client.search(session_id, "T1Mem", limit=5, mode="hybrid")
113
+ assert len(hits_h) >= 1
114
+ # 不存在的 mode 应抛 422
115
+ with pytest.raises(T1MemError) as exc:
116
+ client.search(session_id, "T1Mem", limit=5, mode="nonsense")
117
+ assert exc.value.status == 422
118
+
119
+
120
+ def test_w92_09_pagination(client, session_id):
121
+ """W9.2.9: listSession 分页"""
122
+ for i in range(8):
123
+ client.ingest(session_id, "user", f"page-{i}")
124
+ p1 = client.list_session(session_id, limit=3, offset=0)
125
+ p2 = client.list_session(session_id, limit=3, offset=3)
126
+ p3 = client.list_session(session_id, limit=3, offset=6)
127
+ assert len(p1) == 3
128
+ assert len(p2) == 3
129
+ assert len(p3) == 2 # 8 - 6 = 2
130
+ # 倒序:p1[0] 最新, p1[-1] 早于 p2[0]
131
+ assert p1[0].created_at >= p1[-1].created_at
132
+ assert p1[-1].created_at >= p2[0].created_at
133
+
134
+
135
+ def test_w92_10_404_raises(client):
136
+ """W9.2.10: 不存在的 memory_id 抛 404"""
137
+ with pytest.raises(T1MemError) as exc:
138
+ client.get(f"nonexistent-{uuid.uuid4()}")
139
+ assert exc.value.status == 404
140
+
141
+
142
+ def test_w92_11_session_isolation(client):
143
+ """W9.2.11: 不同 session 完全隔离"""
144
+ sid_a = f"py-sdk-w92-A-{uuid.uuid4().hex[:6]}"
145
+ sid_b = f"py-sdk-w92-B-{uuid.uuid4().hex[:6]}"
146
+ client.ingest(sid_a, "user", "A 的项目是 T1Mem")
147
+ client.ingest(sid_b, "user", "B 的项目是 Mnemosyne")
148
+ list_a = client.list_session(sid_a)
149
+ list_b = client.list_session(sid_b)
150
+ assert len(list_a) == 1
151
+ assert len(list_b) == 1
152
+ assert "T1Mem" in list_a[0].content
153
+ assert "Mnemosyne" in list_b[0].content
154
+ # 跨 session 搜不到
155
+ hits = client.search(sid_a, "Mnemosyne", limit=5)
156
+ assert all("Mnemosyne" not in h.content for h in hits)
157
+
158
+
159
+ def test_w92_12_connection_error():
160
+ """W9.2.12: 连接错误抛 T1MemError(0)"""
161
+ bad = T1MemClient("http://127.0.0.1:1", timeout=1.0)
162
+ with pytest.raises(T1MemError) as exc:
163
+ bad.health()
164
+ assert exc.value.status == 0