# 群记忆花园 API

这是群助手接入记忆库的**全部**公开业务接口。只有两个：

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/memory/add` | 把一条内容写成一页 markdown |
| `GET` | `/api/memory/search` | 按关键字 grep 记忆 |

两个接口都要求请求头：

```http
X-Memory-Key: <MEMORY_API_KEY>
```

也接受 `Authorization: Bearer <MEMORY_API_KEY>`。`MEMORY_API_KEY` 从哪来：

1. 部署环境显式设置 `MEMORY_API_KEY`；
2. 否则 App 首次启动生成一枚，保存在 `$ECHO_APP_DATA_DIR/api-key`（0600）。
   在 Echo 应用中心预览页的「FOR THE GROUP ASSISTANT」区域可以复制它。

**不要把 Key 放进浏览器前端或群聊消息里。** 它只应出现在群助手的服务端环境变量中。

---

## POST /api/memory/add

请求体：

```json
{
  "content": "周五下午整理周报，先写风险再写进展。",
  "title": "周报习惯",
  "tags": ["周报", "习惯"],
  "projectId": "project-123",
  "source": "group-assistant",
  "dedupeKey": "weekly-report-preference-v1",
  "metadata": { "channelId": "c-1", "topicId": "t-9" }
}
```

字段：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `content` | 是 | 记忆正文，UTF-8 最多 64 KiB；换行会原样保留 |
| `title` | 否 | 标题；不传时取正文第一个非空行，最多 120 字 |
| `tags` | 否 | 字符串数组，最多 20 个、每个最多 40 字 |
| `projectId` | 否 | 项目分区；也可以放在 `X-Memory-Project` 头里；默认 `default` |
| `source` | 否 | 来源说明，最多 200 字（如 `group-assistant`、`weekly-job`） |
| `dedupeKey` | 否 | 同一项目内的幂等键；命中已有文件时直接返回旧记忆，不重复写 |
| `metadata` | 否 | 任意 JSON 对象，最多 8 KiB；会原样存进 frontmatter 的 `meta` |

成功响应 `201`（命中 `dedupeKey` 时 `200`）：

```json
{
  "ok": true,
  "memory": {
    "id": "mem_m3k2x1a4f0c9d8e7",
    "title": "周报习惯",
    "path": "default/20261114-080910-周报习惯-k2x1a4.md",
    "projectId": "project-123",
    "createdAt": "2026-11-14T08:09:10.000Z",
    "tags": ["周报", "习惯"],
    "source": "group-assistant",
    "bytes": 74,
    "deduplicated": false
  }
}
```

行为：

- 一页 markdown 一个文件；写入是**临时文件 + rename** 的原子替换，权限 `0600`；
- 文件名是 UTC 时间 + 标题 slug + id 尾缀；真正的内容在正文，frontmatter 保存 id/title/时间/项目/标签/来源；
- 同一项目同一个 `dedupeKey` 不会长出第二页；不同项目各自独立。

示例：

```sh
curl -sS -X POST "$APP/api/memory/add" \
  -H "Content-Type: application/json" \
  -H "X-Memory-Key: $MEMORY_API_KEY" \
  -d '{
    "projectId": "project-123",
    "content": "周五下午整理周报，先写风险再写进展。",
    "title": "周报习惯",
    "tags": ["周报"]
  }'
```

---

## GET /api/memory/search

查询参数：

| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `q` | 是 | 关键字，1–200 字；固定串、忽略大小写，不做正则/分词 |
| `projectId` | 否 | 只搜这个项目；也可以用 `X-Memory-Project` 头；不传则跨全部分区 |
| `limit` | 否 | 返回条数，默认 20，最大 100 |
| `offset` | 否 | 跳过条数，默认 0 |

检索方式：

1. 用系统 `grep -R -I -l -i -F --include=*.md` 找出包含关键字的文件（固定串，不解释正则）；
2. 对命中的文件逐行生成 `matches`（行号 + 片段）和 `snippet`；
3. 按命中次数、标题命中加权、创建时间排序；
4. `grep` 不存在或执行失败时退回同语义的 JS 逐行扫描，响应里的 `grep` 字段会是 `js-fallback`。

响应：

```json
{
  "ok": true,
  "query": "周报",
  "projectId": "project-123",
  "total": 1,
  "items": [
    {
      "id": "mem_m3k2x1a4f0c9d8e7",
      "title": "周报习惯",
      "path": "project-8a0d.../20261114-080910-周报习惯-k2x1a4.md",
      "projectId": "project-123",
      "createdAt": "2026-11-14T08:09:10.000Z",
      "tags": ["周报"],
      "source": "group-assistant",
      "score": 2,
      "snippet": "周五下午整理周报，先写风险再写进展。",
      "matches": [{ "line": 12, "text": "周五下午整理周报，先写风险再写进展。" }]
    }
  ],
  "grep": "grep"
}
```

示例：

```sh
curl -sS "$APP/api/memory/search?q=%E5%91%A8%E6%8A%A5&projectId=project-123" \
  -H "X-Memory-Key: $MEMORY_API_KEY"
```

---

## 错误形状

非 2xx 一律：

```json
{
  "ok": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "缺少或错误的 X-Memory-Key"
  }
}
```

常见错误码：

| HTTP | code | 含义 |
| --- | --- | --- |
| 400 | `EMPTY_CONTENT` / `EMPTY_QUERY` | 正文或关键字为空 |
| 400 | `INVALID_JSON` / `INVALID_TAGS` / `INVALID_PROJECT` | 请求形状不合法 |
| 401 | `UNAUTHORIZED` | Key 缺失或不匹配 |
| 404 | `MEMORY_NOT_FOUND` | UI 读单页时没找到 |
| 413 | `CONTENT_TOO_LARGE` / `METADATA_TOO_LARGE` | 超过上限 |
| 500 | `INTERNAL` | 服务内部错误（详情只进日志） |

---

## 管理页内部接口（不是公开 API）

页面用 `X-App-Session` 调用以下同源接口，会话由 CoLink 页面身份或 Echo 预览票据换取，30 分钟有效：

- `POST /ui/session`：CoLink 页面登录后换会话；
- `POST /ui/preview-session`：Echo 预览票据换会话（只在预览页展示 API Key）；
- `GET /ui/files`：列出可见记忆；
- `GET /ui/file?path=...`：读一页原文；
- `GET /ui/search?q=...`：页面内的 grep 搜索。

这些接口**不**接受 `X-Memory-Key`，也**不**对外承诺兼容性；群助手只用上面两个 `/api/memory/*`。
