Metadata-Version: 2.1
Name: n1mem
Version: 0.4.1
Summary: N1Mem memory API client + local memory importers (BYOK, zero hard dependencies)
Author: N1Mem (powered by T1Mem engine)
License: Proprietary
Project-URL: Homepage, https://www.n1mem.com
Keywords: memory,llm,rag,agent,n1mem,t1mem,import,migration
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: async
Requires-Dist: httpx >=0.24 ; extra == 'async'

# n1mem · N1Mem 记忆 API Python SDK

> powered by T1Mem engine · BYOK（自带 Key）· 同步**零硬依赖**

给 Agent 一个长期记忆后端：**写入即可召回**，召回基于命中记忆接地；
并且能把你**已有的 Agent 记忆**（身份文件 / 技能库 / 关键文档）一次性迁进来。

## 安装

```bash
pip install n1mem            # 同步版，零硬依赖（仅标准库）
pip install n1mem[async]     # 需要异步客户端时（依赖 httpx）
```

## 10 行代码跑通

```python
from n1mem import N1Mem

m = N1Mem(api_key="tk_xxx")        # 或设环境变量 N1MEM_API_KEY 后不传参

print(m.health())                  # 服务健康 + provider 可用性
m.ingest("我今天换了新工位，在 3 楼靠窗")
print(m.recall("我坐哪儿"))
print(m.list_memories(limit=5))    # 看看存进去了什么
```

## 无感接入：`N1MemChat`（OpenAI 兼容客户端包装层）

不改任何对话代码与提示词习惯：请求前自动召回注入、偏好陈述自动写入。
记忆服务故障时聊天照常（fail-open），运行状态看 `client.stats`。

```python
from openai import OpenAI
from n1mem.chat import N1MemChat

client = N1MemChat(OpenAI(api_key=SK), api_key="n1mem-xxx")
client.chat.completions.create(model="qwen3.7-flash",      # 与原代码完全同形
                               messages=[{"role": "user", "content": "我的交付物偏好是什么"}])
print(client.stats)   # {"recall_calls":1, "recall_hits":2, "write_calls":1, ...}
```

- 注入上下文显式标注来源（`[N1Mem 长期记忆自动注入]`），条数/总字符双预算封顶；
- 自动写入按**保守启发式**（"以后 / 我喜欢 / 我们用 / remember…"）识别偏好陈述，
  普通问句不写，防止噪音污染召回池；进程内去重；
- 可用开关：`recall_enabled=False` / `write_enabled=False`，`write_async=False` 可同步写入。

## 迁移：把已有 Agent 记忆导进来

`n1mem.importers` 在**你本机**把记忆资产枚举成条目（不上传原始目录结构），
再由 `ingest_batch()` 分批提交。全程离线可复现，不依赖服务端读你的磁盘。

```python
from n1mem import N1Mem
from n1mem.importers import WorkBuddyAdapter

m = N1Mem(api_key="tk_xxx")
ad = WorkBuddyAdapter()                    # 默认导入身份层 + 技能层

plan = ad.plan(ad.enumerate())             # 先看要导什么，再决定导不导
r = m.ingest_batch(plan, source_id=ad.source_id)
print(r["inserted"], r["source_id"])

# 反悔：按批次整体撤销（被删内容会进 tombstone，防止同内容被后续导入"复活"）
m.import_rollback(source_id=r["source_id"])
```

已支持的适配器：

| 适配器 | 来源 | 导入内容 |
|---|---|---|
| `WorkBuddyAdapter` | `~/.workbuddy` | L1 身份（常驻生效）· L2 技能 · L4 项目笔记 |
| `DocAdapter` | 指定目录 | L3 关键文档（PRD / 架构 / 计划 / 复盘 / ADR / 规范），按章节切分 |
| `CodexAdapter`（0.3.2 新增） | `~/.codex` | 规则文件（AGENTS.md 等），只读枚举 |
| `ClaudeAdapter`（0.3.2 新增） | `~/.claude` | 规则资产（CLAUDE.md / rules），只读枚举 |
| `CursorAdapter`（0.3.2 新增） | `~/.cursor/rules/` | 规则文件，只读枚举 |

三个新适配器产出的条目带 **`kind` 标记**（`memory` / `rule` / `persona`，0.3.2 起
一等字段并随 `ingest_batch` 透传到服务端落 `metadata['kind']`）：人格类文件
（SOUL.md / IDENTITY.md 等）会被标为 `persona`——它们走常驻身份注入通道，
**不会出现在组织共享召回结果里**（红线 R2 的识别侧 + 服务端出库侧双重保护）。

> ⚠️ **导入必须走 `ingest_batch()`，不要循环调用 `ingest()`。**
> `ingest()` 只发送 `text`，会丢掉 `tier` / `mtype` / `source_id` / `blob`：
> 结果是身份层从 `resident` 静默降级成普通事实（**常驻身份失效且没有任何报错**），
> 并且因为没有批次 id 而**无法回滚**。

安全（2026-09-17 起口径变更）：导入时服务端会扫一遍疑似凭据（阿里云 AK / PyPI token /
PEM 私钥 / 带密码的 DSN / 高熵串）与个人信息，命中条目**照常入库**并在回执里给出
`detected_secrets`（**只给序号与类型，不回显值**）。

> N1Mem 是中间件，**不做内容脱敏** —— 你决定存什么，我们尊重并严格执行。
> 服务端的责任是：系统安全、不主动泄密、主动保护隐私（租户隔离 / 最小权限 /
> 静态加密 / 访问留痕）。若你不想让某内容进库，请在**入参里就不要发它**。
> `blocked_secrets` 为兼容字段，此后恒为空数组。

## 一键同步：`python -m n1mem.core_sync`（0.3.5 新增）

不想写代码的机器（或排查现场）用随包命令把**本机核心记忆文件与技能**同步到 N1Mem：

```bash
python -m n1mem.core_sync                # 默认：identity + skill
python -m n1mem.core_sync --dry-run      # 只看将同步什么，不写入
python -m n1mem.core_sync --no-skill     # 只同步身份四件（排查噪音时用）
python -m n1mem.core_sync --with-skill-packs   # 连技能 zip 包一起传（大）
```

- **默认同步 identity + skill 两层**：身份四件照常；技能**默认只传摘要**
  （SKILL.md 前 3000 字 + 用途），够召回与浏览 —— 60 个技能的 zip 包有几个 MB，
  装一次传一次不划算，要看包再加 `--with-skill-packs`；
- 技能记录 `metadata.file` 为空 ⇒ **天然不落「核心文件」分组**（页面侧目录树
  同步取消了技能分组）——「核心记忆文件里没有 SKILL」不需要靠不同步来达成；
- 配置不全（缺 `N1MEM_API_KEY` / `N1MEM_INSTANCE`）⇒ 退出码 2 并明确指出缺哪个，
  **不静默丢**；未知层名同样硬拒（退出码 2）；
- 运行留痕：`~/.n1mem/core_sync_log.jsonl`（每行一条 JSON 记录）。

## 对话提炼：`n1mem.distill`（0.3.0 起随包提供）

把本机的会话记录**提炼**成记忆条目，而不是把原始对话整段灌进去 ——
单条对话最长可达数十万字符（实测 47 万），远超任何上下文窗口，
不分块直接发必然失败。

它的核心设计是**把「算钱」和「花钱」分成两步**，让费用在发生前就可见：

| 步骤 | 是否花钱 |
|---|---|
| `scan_dialogues()` —— 数出待提炼的轮次 | **纯本地**，零网络、零 LLM 调用 |
| `estimate(...)` —— 换算成预估费用 | **纯本地** |
| `plan_chunks()` / `build_prompt()` —— 分块、拼提示词 | **纯本地**（可在 dry-run 里先看分块是否合理） |
| `distill_chunk(...)` —— 真正提炼一块 | ⚠️ **本模块唯一会花钱的一步**，必须由调用方显式触发 |

```python
from n1mem import distill

st  = distill.scan_dialogues()                   # 本地扫描，不花钱
est = distill.estimate(st, price_in_per_m=1.0,   # 本地估算，仍不花钱
                       price_out_per_m=4.0)
print(est["turns"], est["est_cost_total_cny"])   # 先看清要花多少
# 确认之后，才逐块调用 distill_chunk(complete=…, chunk=…, model=…)
```

`estimate()` 的返回值刻意分成两组，**别混着看**：

* `certain_fields`（`turns` / `chars` / `skipped_*` / `merged_user`）—— 本地数出来的**确定值**；
* `estimated_fields`（token 数、金额）—— 按 `assumptions`（字符/token 比、块大小、单价）
  **换算的估算值**；输出长度由模型决定，实际费用可能高于或低于此数。

超长条目**跳过并计数**（`skipped_oversize`），不会悄悄截断。

## API

| 方法 | 对应端点 | 说明 |
|---|---|---|
| `health()` | `GET /health` | 健康与 provider 状态，无需鉴权 |
| `metrics()` | `GET /metrics` | Prometheus 文本指标 |
| `ingest(text, purpose="recall", visibility="org", cat="", project="")` | `POST /v1/ingest` | 写入一段记忆（持久化 + 建向量，写入即可召回）；可选 `cat` 标「踩坑」、`project` 标项目归属 |
| `recall(prompt, purpose="recall", mode=None)` | `POST /v1/recall` | 按提示召回；返回 `answer`（已基于命中记忆接地）与 `retrieved`（命中明文） |
| `ingest_batch(items, source_id=…)` | `POST /v1/ingest/batch` | 批量导入，自动切块并汇总；幂等（同批重跑 `inserted=0`） |
| `list_memories(limit=50, …, with_content=False)` | `GET /v1/memories` | 列出本租户记忆；默认只给预览，要全文须显式开 |
| `forget(memory_id)` | `POST /v1/forget` | 删除指定记忆（仅本租户可见，删除后无法召回） |
| `import_rollback(source_id=…)` | `POST /v1/import/rollback` | 按批次 / 来源目录撤销导入 |
| `forget_all(confirm=True)` | `POST /v1/memories/all` | 清空本租户**全部**记忆（须显式 `confirm=True`） |
| `ask / update` | — | **尚未提供**，调用会明确抛 `NotImplementedError` |

异步版 `AsyncN1Mem` 接口完全一致：

```python
from n1mem import AsyncN1Mem

async with AsyncN1Mem(api_key="tk_xxx") as m:
    await m.ingest("…")
    await m.ingest_batch(items)
```

### 写入标记：`cat` / `project`（0.3.6 新增）

`ingest()` / `AsyncN1Mem.ingest()` 多了两个**可选**关键字参数。两个都不传 ⇒ 不发字段、
服务端不写键，**行为与 0.3.5 完全一致**（可直接回退）。

| 参数 | 取值 | 语义 |
|---|---|---|
| `cat` | `""`（默认）/ `"pitfall"` | 合集标记。`"pitfall"` = 踩坑经验，进「踩坑合集」，**全队可召回** |
| `cat` | 其它任何值 | 服务端 **400 `cat_malformed`，且不落库**（SDK 抛 `N1MemError`） |
| `project` | `str`（≤64 字） | 项目名。填了归入该项目的「产品知识库」；不填 = 用户级「团队知识库」 |

`cat` 是**闭集不是自由文本**：合集是产品概念，一放开自由值，「踩坑合集」就会混进
「临时 / 想法 / 待办」，最后没人敢用它查东西。所以写错值**当场报错**，不静默忽略。

`project` 服务端**只清洗、不归并**：控制字符（含换行/制表符）→ 空格、压缩连续空白、
截断到 64 字。`T1mem` 不会被纠成 `T1Mem`，`n1mem-mcp` 与 `n1mem_mcp` 也不会合并 ——
项目名是**你的事实**，纠正了你就按自己写的名字查不到那条记忆。
注意 `"T1Mem\n2026"` → `"T1Mem 2026"`（换行变空格，不是删除；删除会粘成 `T1Mem2026`）。

三个可复制的例子：

```python
from n1mem import N1Mem, N1MemError

m = N1Mem(api_key="tk_xxx")

# ① 普通记忆：什么都不传，与旧版行为一致
m.ingest("我在 3 楼靠窗的工位")

# ② 踩坑经验：进「踩坑合集」，全队 Agent 都能召回
#    最有价值的写法是写满三段：现象 / 真因 / 下次怎么防
m.ingest(
    "部署包漏了大对象：清单对了但货没到。真因是打包只按 manifest 走、"
    "没校验对象存储的实际存在性。下次发版前先跑一次对象存在性核对。",
    cat="pitfall",
    project="T1Mem",
)

# ③ 只标项目归属，不标踩坑
m.ingest("T1Mem 的迁移脚本统一先跑 --plan dryrun，不要直接 apply。", project="T1Mem")

# ④ 写错合集名 ⇒ 服务端 400 拒绝，且**不落库**（不是静默忽略）
try:
    m.ingest("随便写的分类", cat="random")
except N1MemError as e:
    print(e.status, e.message)   # 400 {"detail":{"reason":"cat_malformed", ...}}
```

异步版完全一致：

```python
async with AsyncN1Mem(api_key="tk_xxx") as m:
    await m.ingest("…", cat="pitfall", project="T1Mem")
```

## 环境变量

| 变量 | 说明 | 默认 |
|---|---|---|
| `N1MEM_API_KEY` | 你的 Key（BYOK）。`N1Mem()` 未显式传 `api_key` 时从这里取 | 空 |
| `N1MEM_SUBJECT` | **主体名**：谁在调用（人 / 设备）。见下节 | 空 |
| `N1MEM_BASE_URL` | API 地址（构造参数 `base_url` 优先） | `https://api.n1mem.com` |
| `N1MEM_INSTANCE` | **实例标识**：设备级授权载体 `<local_id>:<secret>`（0.3.1 新增）。开「实例强制」模式时必传 | 空 |
| `N1MEM_KEEPALIVE` | 设为 `0` 关闭连接复用。默认开启：同一 `N1Mem` 实例的后续请求复用同一条 TCP + TLS 连接（省掉二次建连的两个 RTT）；配了代理时复用同一条 CONNECT 隧道。只在"中间设备对 keep-alive 处理异常"时才需要关 | `1` |

显式传入的构造参数**优先于**环境变量。

## 主体声明：`N1MEM_SUBJECT`（0.3.0 新增）

**Key 回答「哪个组织」，`N1MEM_SUBJECT` 回答「谁」。**

同一个 Key（同一组织）下可能有多台设备 / 多个人在调用。声明主体后，服务端按
`(org, subject)` 反查成员表，据此判定该主体是否已被接入。

```python
from n1mem import N1Mem

m = N1Mem(api_key="tk_xxx", subject="workbuddy-pc")   # 也可设环境变量 N1MEM_SUBJECT
```

## 实例身份：`N1MEM_INSTANCE`（0.3.1 新增）

**主体回答「谁」，实例回答「哪台设备」。**

格式 `<local_instance_id>:<密钥串>`。服务端据此把写入绑定到具体设备，支持设备级的
「撤销 / 轮换」：撤销某个实例后，该设备的后续请求立即失效，不影响同组织其它设备。

```python
from n1mem import N1Mem

m = N1Mem(api_key="tk_xxx", instance="my-pc:abc123")   # 也可设环境变量 N1MEM_INSTANCE
```

> 实例强制模式（服务端 `N1MEM_REQUIRE_INSTANCE=1`）开启前，客户端需先完成实例登记；
> 当前默认关闭，不传实例仍可正常调用。

SDK 会把主体作为 `x-n1mem-agent` 请求头发给服务端。

* **须与组织里已登记的主体名逐字一致**。拼错不会报错 —— 服务端只会认为「该主体未登记」。
* **不设 ⇒ 不发这个头**，行为与本能力引入前完全一致 ⇒ 随时可回退（去掉该键即可）。
* 🔴 **别拿 Agent 的类型名当主体名**（如 `workbuddy`）：那是「哪个 Agent」，不是「谁」。
  一台机器同时跑多个 Agent 时，它们**应当共享同一个主体**；否则该共享的 `private`
  记忆反而互相看不见。
* 想确认服务端认到什么：调 `/v1/member/status` —— 未声明主体时它明确回报
  `missing_subject`，不含糊。

配套 MCP Server 有同样的开关：见 [`n1mem-mcp`](https://pypi.org/project/n1mem-mcp/)。

## 连接级故障转移（0.3.7 新增）

**问题**：`api.n1mem.com` 的 DNS 会返回多个 A 记录，其中个别地址是 **L4 黑洞**——
SYN 发出去没有任何回应，也不回 RST。Python 的 `http.client` 是**串行**建连的，
撞上一个黑洞就要等满系统上限：单个黑洞实测 **21.03s**，排在响应列表第 k 位就是
≈k×21s（实测 22s / 43s / 106s）。这既不是你的代码问题，也不是服务端慢，纯粹卡在
TCP 建连。

**做法**：发请求前先**并发探测**（每个地址 3s 上限）挑一个连得上的，然后在这个请求
的作用域内把域名解析**钉**到它上面。

```python
from n1mem import N1Mem

m = N1Mem(api_key="tk_xxx")      # 无需任何配置，默认开启
m.ingest("……")
```

**边界（诚实说明）**：

* 只在**解析出 ≥2 个地址**时探测。单地址不探（省掉一次多余握手，行为零变化）。
* 全部不可达 / 解析失败 ⇒ **原样回落** `urllib.request.urlopen`，**不新增异常类型**
  （原来的异常照样冒出来，你可以继续按原方式捕获）。
* URL 仍然是**域名**，不是「连 IP + 手写 Host 头」——后者会让 SNI 与证书校验失效，
  所以刻意不用。
* 解析覆盖是**线程本地**的，且**请求结束（含异常路径）必定还原**，不污染进程里其它
  代码的 DNS 行为。
* ⚠️ **只覆盖同步客户端 `N1Mem`**。`AsyncN1Mem` 走 httpx，多地址策略由底层传输栈
  决定，本轮未接入 —— 异步路径上的黑洞拖慢仍然存在（已列入后续）。
* 自动化 / CI / 离线环境：设 `T1MEM_OFFLINE=1` 可整段关闭（探测本身就是真实建连，
  离线环境必须关，否则会白等）。

> 一句话回顾 0.3.6 及之前：**响应慢 ≠ 服务端慢**。先看是不是卡在建连 —— 抓一次
> `connect` 阶段的耗时即可分辨。

## recall 域过滤与 meta 闭环（0.4.1 新增）

### `domain`：把召回收窄到一个域

`recall(prompt, domain="pitfalls")` 只在该域的记忆里召回。可取值（闭集，**服务端校验**）：

| 值 | 装什么 |
|---|---|
| `project-conventions` | 本项目的约定（路径、命令、命名、兼容口径） |
| `pitfalls` | 踩过的坑（现象 / 真因 / 下次怎么防） |
| `decisions` | 拍过的板（选了哪个方案、为什么否掉别的） |
| `handoff` | 交接条（谁交给谁、文件域、未完成项） |
| `user-preferences` | 用户偏好（称呼、交付形式、铁律） |

不传 = 不收窄，与旧版行为逐字节一致（请求体里连这个键都不出现）。传了非法值 ⇒ 服务端
422 `unknown_domain`，**不是静默忽略** —— 静默忽略会让调用方以为收窄了，而其实拿全库
的结果当某个域的证据用。

### `meta`：让 Agent 知道"这次召回到底拿到了什么"

返回体多一个 `meta` 键。它是**旁路信号**：`answer` / `retrieved` 的形态完全不变，
不关心它的代码可以原样忽略。

| 字段 | 含义 | Agent 该怎么用 |
|---|---|---|
| `retrieved_count` | 命中条数 | 为 0 时别硬答：问用户或换问法 |
| `retrieved_ids[i]` | 与 `retrieved[i]` **同序同长** | 对第 i 条直接 `edit_memory` / `forget` |
| `types` | 各分类的条数分布 | 一眼看出"只召回到踩坑、没有约定"这类偏斜 |
| `freshness` | 最新 / 最旧一条的日期 | 太旧（比如半年前）就要怀疑已被现实推翻 |
| `coverage` | 你问的词覆盖到哪些、漏了哪些 | `missing` 非空 ⇒ 换问法再召一次 |
| `confidence` | high / mid / low | low 时应当去问，不要自己补 |
| `domain` | 本次是否按域收窄（`null` = 没传） | 排查"为什么只回来这几条" |

**为什么 `retrieved_ids` 值得单说**：以前召回后想修正一条旧记忆，只能再 `ingest`
一条新的 —— 同一个事实在库里越攒越多，召回越来越吵。现在能按下标直接改：

```python
r = n1mem.recall("部署要带哪个环境变量", domain="pitfalls")
for text, mid in zip(r["retrieved"], r["meta"]["retrieved_ids"]):
    if "旧口径" in text and mid:          # mid 为空串 ⇒ 这一条本次改不了，跳过
        n1mem.edit_memory(mid, "新口径：…", reason="口径变更")
```

⚠️ **空串不等于"这条不可信"**：`retrieved_ids[i] == ""` 只表示"这一条的 id 本次没拿到"。
两种误读方向相反，都会白烧一次调用：
· 当成「整批都不可信」⇒ 白丢一次本来能完成的修正（其余条目是有 id 的）；
· 当成「这条也能改」⇒ 拿空串去调 `edit_memory`，必然失败。

## 连接复用与代理隧道（0.4.0 新增）

**问题**：`urllib.request.urlopen` 没有连接池，而且会主动发 `Connection: close` ——
每个请求都要重付「TCP 建连 + TLS 握手」。现场实测（跨境链路，RTT ≈ 315ms）：首次
697ms，同一条连接上的后续请求 324–347ms。链路慢不是我们能改的，**少两个 RTT 却是**。

**做法**：同步路径换成标准库 `http.client` 持久连接 + 进程级连接池：

* 有活着的空闲连接 ⇒ 零探测、零握手直接发；
* 没有 ⇒ 照旧先做多地址探测（见上一节），再在"钉住解析"的窗口内建连；
* 空闲连接被对端回收（keep-alive 到期 / 中间设备清理）⇒ 自动重建并**只重试一次**
  —— 「连接被关」不会变成你看到的失败；
* `N1Mem.close()` 与进程退出都会关掉空闲连接（另加 45s 空闲上限）。

**挂代理也一样有收益**（本版相比上一版的变化）：此前一旦检测到 `http_proxy` /
`https_proxy` 就整段退回 `urlopen` —— 出口正确，但拿不到任何复用收益。现在改成
**在代理上开 CONNECT 隧道，并复用这条隧道**：

* 出口读 `urllib.request.getproxies()`（环境变量优先，其次系统配置），与 urllib 同口径；
* `no_proxy` 命中 ⇒ 直连，不隧穿；
* 代理 URL 带 `user:pass@` ⇒ 自动带 `Proxy-Authorization: Basic …`（凭据不会进日志）；
* `https` 目标在隧道内**对目标域名**做 TLS（SNI 与证书主机名都是目标，不是代理），
  校验强度与直连完全相同；
* 代理拒绝 CONNECT（407 / 403）⇒ 回落 `urlopen` —— **还是走你配的那个代理**，只是少了
  复用；**不会**悄悄改成直连（那等于替你把出口策略换掉）。

**边界（诚实说明）**：

* 只覆盖同步客户端 `N1Mem`。`AsyncN1Mem` 走 httpx，有它自己的连接池。
* 3xx 重定向交回 `urlopen`：池不实现重定向，也不偷偷重发非幂等请求。
* 隧道也是长连接，代理重启 / 空闲回收同样会让它失效 —— 那时自动重建并重试一次。
* 想回到"每个请求一条连接"：`N1MEM_KEEPALIVE=0`。

> 一句话：**同一目标的第二个请求，不该再付一次建连的钱** —— 直连和走代理都是。

## 错误处理

上游 4xx/5xx 统一抛 `N1MemError`，带 `status` / `message` / `endpoint`：

```python
from n1mem import N1MemError
try:
    m.ingest("x")
except N1MemError as e:
    print(e.status, e.endpoint, e.message)   # 401 /v1/ingest {"detail":"invalid api key"}
```

参数用错（如 `forget_all()` 未确认、`import_rollback()` 两个参数都没给）会抛
`ValueError` / `TypeError` —— **在本地就失败**，不浪费一次注定被拒的网络请求。

## 当前能力边界（诚实清单）

- `ingest()` 持久化到 N1Mem 存储层并生成向量，返回 `{stored, embedded, memory_id}`；写入后即可被召回。
- `recall()` 走关键词 + 向量混合检索（hybrid）；`answer` 基于命中记忆接地生成，未命中会**诚实说明未命中**，不编造。
- 租户隔离由服务端按 API Key 反查 org 保证，**不存在传参越权读取的路径**。
- `ask` / `update` 尚未提供，SDK 会明确抛 `NotImplementedError`，而不是静默返回空。
- 0.3.2 起：`importers` 提供 CodeX / Claude / Cursor 三个规则文件适配器；
  条目带 `kind`（memory/rule/persona）并透传服务端。**persona 条目若出现在
  组织共享召回结果里属于红线事故**——服务端已做查询层排除（0.3.2 同批上线）。
- 0.3.5 起：随包提供一键同步命令 `python -m n1mem.core_sync`——
  **默认同步 identity + skill**（技能只传摘要，`--with-skill-packs` 才传 zip 包，
  `--no-skill` 只同步身份四件），见上文「一键同步」一节。
- 0.3.6 起：`ingest()` 支持 `cat` / `project` 两个写入标记（见上文
  「写入标记」一节）。`cat` 闭集（`""` / `"pitfall"`），非法值 400 且**不落库**；
  `project` 只清洗、不做相似度归并；两者都**不传 ⇒ 不写键**。
- 0.3.7 起：连接级故障转移——解析出多个地址时先并发探测（每地址 3s 上限）挑一个
  连得上的，避免被 CDN 里的 L4 黑洞逐个拖满 21s。**只覆盖同步 `N1Mem`**，
  `AsyncN1Mem`（httpx）未接入（见上文「连接级故障转移」一节）。
- 0.4.0 起：连接复用与代理隧道——同一目标的后续请求复用同一条 TCP + TLS 连接
  （省两个 RTT）；配了代理时不再退回 `urlopen`，而是在代理上开 CONNECT 隧道并复用
  隧道，`no_proxy` 命中则直连。见上文「连接复用与代理隧道」一节。
- 0.4.1 起：recall 域过滤与 meta 闭环——`recall(prompt, domain=…)` 按 5 个域收窄；
  返回体多 `meta`，其中 `retrieved_ids` 与 `retrieved` 同序同长 ⇒ Agent 能按下标
  直接修正旧记忆，而不是再写一条新的。见上文「recall 域过滤与 meta 闭环」一节。

### 历史勘误

| 版本 | 曾经的错误说法 | 现状 |
|---|---|---|
| ≤ 0.1.2 | README 写「Phase 0 不持久化、recall 取不回」 | 已随 C-2 存储层 + 召回接地修复而过时，勿再引用 |
| ≤ 0.1.5 | 包内 `__version__` 停在 `0.1.2`，与 pyproject 不一致 | 0.2.0 已对齐，并有测试钉住（防再漂移） |

## 平台内 Agent 协作（沟通通道）

平台在**记忆能力之外**提供一条 Agent 间沟通通道（线程 / 消息 / 接力装配 /
要点沉淀），解决"同组织内多个 Agent 各自为战、上下文无法接力"的问题。
能力域（2026-10 上线）：

- **线程与消息**：Agent 以主体声明（`N1MEM_SUBJECT`）身份建线程、发消息、
  按序读线程；消息带 `seq` 与 `client_msg_id`（幂等，重放返回原消息）。
- **接力装配**：换班/跨会话时取「接力上下文包」（`POST /v1/comm/handoff/assemble`）
  与「接续摘要」（`GET /v1/comm/threads/{id}/handoff-summary`，服务端 LLM 生成），
  让接手 Agent 快速进入状态。
- **感知召回**：召回同组织内**其他 Agent** 的沟通消息（按线程 / prompt 收敛）。
- **要点沉淀**：超管在控制台提炼线程要点并沉淀为组织级 curated 记忆
  （引用源消息、不复制；源消息历史原样保留）。

**边界（诚实清单）**：

- 沟通通道是**平台内能力**，**不改变本 SDK 与 `n1mem-mcp` 的对外工具契约**——
  `memory_write` / `memory_recall` 等 9 个 MCP 工具与 SDK 公开方法集合不变，
  本轮**不新增**沟通类工具或 SDK 方法。
- 组织级灰度开关（默认关闭）：未开量的组织调用沟通端点会收到
  `C06001 comm_gray_not_enabled`，而非静默降级。
- Agent 间可见性受组织隔离（RLS）与主体声明双重约束；主体缺失返回 403。

> 需要以沟通通道做 Agent 编排时，直接对接 C-2 API（`/v1/comm/*`、`/v1/console/comm/*`）；
> 契约、鉴权与错误码随后续 SDK 版本再评估是否纳入。

## 与 `t1mem_sdk` 的区别

同目录下有两个包，**不要混用**：

| 包 | 对接对象 | 用途 |
|---|---|---|
| `n1mem` | 线上 C-2 API（`https://api.n1mem.com`） | **对外发布**，本 README 描述的对象 |
| `t1mem_sdk` | 本地 t1mem-core API（`http://127.0.0.1:8080`） | 内部使用，接口为 `/memories`、`/sessions`、`/stats` |

## 相关：MCP Server

想让 Claude / Cursor / OpenClaw 以**工具**方式调用记忆（而不是写代码），
装配套的 [`n1mem-mcp`](https://pypi.org/project/n1mem-mcp/)。
