# 嵌入指南：把 pi-starter 装进你已有的服务

面向**已有 Express / HTTP 服务**、想把 Agent 当能力接进去的开发者。

如果你打算直接改 `src/` 跑自己的 CLI 或 Web，[`README.zh-CN.md`](../README.zh-CN.md) 的「二次开发」一节就够了，不需要本文。本文只讲另一条路：**把脚手架当依赖装上，不改它一行代码**。

本文里的每个结论都在**构建产物**上实测过。仓库内可重复执行：

```bash
npm run verify:embed   # 已并入 npm run verify
```

它打的是 `dist/`（消费者真正拿到的东西），覆盖：内置示例内容的存在与关闭、`createApp` 返回值形状、内核路由可用性、以及下面「鉴权」一节的三种挂载方式对比。`buildAgent()` 需要真实模型凭据，脚本用结构相同的假 agent 代替，因此**不**验证模型选择与凭据读取。

---

## 先选路线

| 你的情况 | 走哪条 |
|---|---|
| 已有 Express 服务，只要一组 REST 接口（SSE 流式对话） | **路线 A**，约 30 行 |
| 要 WebSocket 实时通道（打字机、工具卡片卡片、审批弹窗、多对话） | **路线 B**，成本高，建议 fork |
| 要改内置工具 / 扩展 / guard 规则本身 | fork 改 `src/`。这类需求**没有**库级扩展点，别硬绕 |

判断依据：`lib.ts` 导出的东西是**能力**（`buildAgent` / `createApp` / `attachWebSocket` / 各类 store），`src/` 里的注册表（`tools/index.ts`、`extensions/index.ts`）是**装配**。库能让你加新东西，不能让你改内置清单。

---

## 路线 A：只借 REST

### 1. 装依赖、配好模型凭据

```bash
npm install pi-starter
```

`buildAgent()` 需要一个**已配置 Key 的模型**，来源二选一：

- `.env` 里 `PI_PROVIDER` / `PI_MODEL` / `PI_API_KEY`
- 或跑一次 `npm run setup`（写 `~/.pi/agent/` 下的配置，多 provider 切换用这个更方便）

两个都不配时 `buildAgent()` 直接抛 `No available model.`，不会兜底到别的模型。

### 2. 装配

```ts
import express from "express";
import { buildAgent, createApp } from "pi-starter";
import { join } from "node:path";

const SESSION_DIR = "/var/lib/myapp/pi-sessions";

const agent = await buildAgent({
  // 关键：把包内置的示例内容（about.md / summarize 技能）关掉。
  // 不关的话，那份「介绍 pi-starter 自己」的文档会进你的系统提示词，
  // 而且没有任何其他办法移除——extraKnowledgeDirs / extraSkillPaths 是叠加不是替换。
  builtinKnowledge: false,
  builtinSkills: false,
  builtinPromptTemplates: false, // 同上：不关掉的话内置的 `review` 模板会进斜杠命令清单
  extraKnowledgeDirs: [join(import.meta.dirname, "knowledge")],
  extraSkillPaths: [join(import.meta.dirname, "skills")],
  extraPromptTemplatePaths: [join(import.meta.dirname, "prompts")],

  // 会话落盘 + 只允许从本目录恢复（fail-closed：不传就完全不允许打开旧会话）
  inMemory: false,
  sessionDir: SESSION_DIR,
  allowedSessionRoots: [SESSION_DIR],

  // 内置编码工具档位。默认 off：只开 read（技能要靠它读 SKILL.md），
  // bash / edit / write 必须显式打开。
  builtinTools: "off",
});

const { app: agentApp, addDisposer, dispose } = createApp({
  agent,
  staticDir: false, // 必须。否则会把仓库里构建出来的前端（web/dist）挂到你的服务上
});

// 用你自己的路由。business 路由挂在 agentApp 之外的父应用上（见下面「鉴权」一节）。
const server = express();
server.get("/biz/stats", (_req, res) => res.json({ ok: true }));
server.use("/agent", agentApp);

addDisposer(() => server.close());
server.listen(3000);

process.on("SIGTERM", async () => {
  await dispose(); // 会顺带跑 addDisposer 登记的东西
});
```

### 3. 你拿到了哪些接口

`createApp` 注册的内核路由，原样挂在 `/agent` 之下：

| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/agent/health`、`/agent/health/ready` | 存活探针（不碰任何依赖）/ 依赖深度探针（模型、技能与知识库目录、数据库） |
| GET | `/agent/info` | 当前模型与可用目录的**完整清单**（原 `/health` 的完整版搬到了这里） |
| GET | `/agent/metrics` | 运行时指标（JSON；`Accept: text/plain` 时给 Prometheus 文本） |
| GET | `/agent/skills`、`/agent/skills/:name` | 技能目录 / 读 SKILL.md |
| GET | `/agent/knowledge`、`/agent/knowledge/search?q=`、`/agent/knowledge/:name` | 知识库目录 / 检索 / 读全文 |
| GET | `/agent/prompt-templates`、`/agent/prompt-templates/:name` | 提示词模板（斜杠命令）目录 / 读正文 |
| GET | `/agent/db`、`/agent/db/notes`、`/agent/db/notes/:id`、`POST /agent/db/query` | sqlite 探活 / 示例表 / 单行 / 只读查询 |
| GET | `/agent/providers` | 各 provider 的鉴权状态（`checkAuth`） |
| POST | `/agent/model` | `{ "model": "provider/modelId" }`，只接受已配 Key 的模型 |
| POST | `/agent/model/cycle` | 在 `scopedModels` 里轮换到下一个模型 |
| POST | `/agent/chat` | `{ "message": "..." }`，**响应是 SSE 流**；加 `?format=jsonl` 走官方原始 JSON 事件流（一行一个事件） |

另外一组**按装配情况挂载**（关掉对应能力时这些路由根本不存在，别按「一定有」来写客户端）：`GET /agent/capabilities` 与 `POST /agent/tools/:name/enabled`（有工具注册表时）、`GET|PATCH /agent/settings`（有设置服务时）、`POST /agent/context/compact` 与 `POST /agent/sessions/import`（有会话中枢时）、`GET|POST /agent/memory` 与 `DELETE /agent/memory/:id`（`PI_MEMORY` 开着时，默认开）、`/agent/files/*`（挂了 `FileService` 时）、`/agent/approval*`（挂了审批规则库时）、`/agent/provider-keys*`（挂了密钥库时）、`GET /agent/logs` 与 `/agent/logs/stats`（配了日志目录时）。**完整、逐条带方法的清单在 [`参考手册.md`](参考手册.md)**，它由 `npm run docs:reference` 从源码生成、`docs:reference:check` 挡漂移。

注意 `POST /chat` 走的是共享 session，内核用 `busy` 闸门挡并发：第二个请求会拿到 429。多用户场景见 README「进阶」。

### 4. 鉴权：只能挂父应用（实测结论）

**错误做法**（README 旧版教的就是这个，已修正）：

```ts
// 写法一：拿到 app 之后再挂
const { app } = createApp({ agent, staticDir: false });
app.use(requireAuth);            // ❌ 完全无效

// 写法二：在 configure 钩子里挂
const { app: app2 } = createApp({ agent, staticDir: false, configure: (a) => a.use(requireAuth) }); // ❌ 同样无效
```

原因：内核路由在 `createApp` 内部注册，`configure` 钩子在**所有内核路由之后**才执行。你后加的中间件排在 `/chat`、`/model` 后面，永远不会被命中。实测这两种写法下，不带 token 请求 `/skills` 仍返回 **200**——等于把 `POST /chat`、`POST /model` 裸奔在所有网卡上。

**正确做法**：把内核 app 当子应用挂进父应用，鉴权排在它前面。

```ts
const { app: agentApp } = createApp({ agent, staticDir: false });

const server = express();
server.use("/agent", requireAuth, agentApp); // ✅ 401 / 200 实测通过
server.listen(3000);
```

附带好处：内核路由不会在父应用根路径上顺带暴露（实测父应用 `/skills` 返回 404），你现有的路由也不会被内核的 `/health`、`/model` 撞掉。

如果你就是想让鉴权中间件在同一个 app 里生效，当前 API 做不到——需要内核支持「先挂中间件再注册路由」。这是已知缺口，不在本文范围。

### 5. 敏感能力默认不注册

`createApp` 只注册上面那组只读 + 对话接口。下列能力**必须显式传**才开：

| 选项 | 开了有什么 |
|---|---|
| `files: new FileService({ root })` | `/files/*`：浏览 / 读 / 写 / 删 / 重命名，限制在 root 内 |
| `approvalRules: store` | `/approval/rules`：查看 / 改审批规则，改动立即落盘 |
| `providerKeys: store` + `applyActiveKey` | `/provider-keys`：运行中替换模型凭据 |
| `settings: new SettingsService(...)` | `/settings` |
| `registry: createToolRegistry(...)` | `/capabilities` 与工具开关 |
| `rateLimit: true` | 限流。默认**关**（本机单用户场景不该被节流） |
| `hub: { setModel }` | 让 `POST /model` 经会话中枢切换，REST 与 WS 模型一致 |

原则：把内核暴露到文件系统、或允许运行中换凭据，都是**嵌入方的决定**，不该由默认值替你做。

### 6. 业务自己的路由

用 `configure`，不要拿到 app 之后再加：

```ts
const { app, seal } = createApp({
  agent,
  staticDir: false,
  configure: (a) => {
    a.get("/biz/report", requireAuth, handler);
  },
});
```

原因：错误处理器由内核在最后挂载。之后加的路由排在它**后面**，抛出的错不会被翻译——实测业务方 `throw new AppError("internal", "数据库密码 db://u:p@h/…")` 会把密码、绝对路径和源码行号原样返回给客户端。`configure` 里的路由在封口之前挂载，享受同一套翻译。

已经加完了才想起封口，用返回值的 `seal()` 补挂一次。

### 7. 回收

`createApp()` / `attachWebSocket()` 的返回值都有 `addDisposer(fn)`。扩展里开的定时器、子进程、临时文件、以及你自己起的 HTTP server，都登记进去；`dispose()` / `close()` 时统一回收。别让嵌入方的清理依赖内核还活着——顺序上它先跑。

---

## 路线 B：要 WebSocket

WebSocket 通道（打字机增量、工具执行卡片、审批弹窗、每连接多对话并发）需要自己装配运行期依赖。`attachWebSocket(server, runtime)` 的 `runtime` 要六样：

| 字段 | 从哪来 |
|---|---|
| `agent` | `buildAgent()` |
| `hub` | `createSessionHub(agent, cfg)` |
| `cfg` | `resolveRuntimeConfig()` |
| `registry` | `createToolRegistry()` |
| `settings` | `new SettingsService(fileSettingsPort(defaultSettingsFile()))` |
| `serverVersion` | 你自己维护的版本串 |

还要按顺序接上审批闸门（`ApprovalGate` + `createPersistentRulesStore`）、限流、优雅停机。这套东西的完整装配顺序见 [`src/server.ts`](../src/server.ts)（`settings → rules → gate → buildAgent → registry → hub → app/http → ws`）。

**建议**：如果只是要 WebSocket 能力，fork 仓库跑 `npm run web`，前面挂自己的反向代理和鉴权，比在业务代码里重写一遍装配更省事也更安全。fork 的前提是接受改 `src/`——那就不是「嵌入」了。

库级暴露的 WS 扩展点（`attachWebSocket` 的 `runtime` 里的可选项，见 `src/transport/ws.ts`）：`commands` 自定义命令（内置命令不可覆盖）、`gate` 审批出口、`uiBridge` HITL 反问出口（官方 `ctx.ui` 挂起的请求按 id 由这里唤醒）、`metrics` 指标注册表（缺省用全局单例）。

HTTP 侧另有三个采集口和一个日志开关，都在 `createApp()` 的选项里（`src/app.ts:71-75`、`:138`）：`sessionStats` / `connectionCount` / `approvalStats` 喂给 `GET /metrics`，`logQuery: { dir }` 一开就挂出 `GET /logs` 与 `GET /logs/stats`（不开则这两条路由根本不存在）。

---

## 自检清单

装之前对一遍，都是实测过的点：

- [ ] 模型凭据已配（`.env` 或 `npm run setup`），否则 `buildAgent()` 抛 `No available model.`
- [ ] `staticDir: false`——否则会挂上仓库里构建出来的 `web/dist` 前端（默认 `staticDir` 就指向它；未构建时 `express.static` 直接穿透，接口不受影响）
- [ ] `builtinKnowledge: false` / `builtinSkills: false` / `builtinPromptTemplates: false`——否则 `about.md`、`summarize` 技能和 `review` 模板会进系统提示词 / 斜杠命令清单
- [ ] 鉴权挂在**父应用**上，不是 `createApp` 之后、也不是 `configure` 里
- [ ] 业务路由在 `configure` 里，或事后调 `seal()`
- [ ] 想要的文件服务 / 审批规则编辑 / 多密钥，都是显式传入的（默认没开）
- [ ] 清理走 `addDisposer` + `dispose()`

---

## 已知缺口

诚实列出当前**做不到**的，避免你在文档里找不到答案时以为是自己用错了：

| 缺口 | 影响 | 绕法 |
|---|---|---|
| 无法在内核路由前插中间件 | 鉴权必须靠父应用挂载 | 见上文「鉴权」 |
| `sessions/store` 未从 `lib.ts` 导出 | 拿不到 `scaffoldSessionDir` / `sessionCatalog`，无法列出磁盘上的历史会话 | 自己给 `sessionDir` / `allowedSessionRoots` 传路径即可做单条恢复；要列表就 fork |
| 无内置示例内容开关的历史版本 | ≤ 0.1.0 需要 `builtinKnowledge` / `builtinSkills` | 用最新版 |
| WebSocket 装配成本高 | 路由 B 需自备 6 个依赖 | fork 跑 `npm run web` + 反代 |

---

## 相关文档

- [`README.zh-CN.md`](../README.zh-CN.md) — 功能边界、进阶扩展点、默认能力
- [`SECURITY.md`](../SECURITY.md) — 内建安全边界与部署前检查
- [`FAQ.md`](../FAQ.md) — 部署与模型切换常见问题
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — 项目地图与自检命令