> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qoder.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 外置会话存储

qodercli 默认把会话历史保存在运行它的机器上。如果服务运行在多台机器、容器或 Serverless 环境中，下一次请求可能由另一台机器处理，导致之前的会话无法继续。

外置会话存储会在你的应用所控制的存储中保留每个会话的一份**镜像**。镜像是一份额外的副本：qodercli 仍然把会话写在本地，任何机器之后都可以通过 session ID 继续同一个会话。

适合使用外置存储的场景包括：

* 服务有多个实例，请求可能在实例之间切换
* 容器或 Serverless 环境的本地磁盘不可靠
* 需要自行管理会话数据的权限、加密、备份或保留周期

如果应用始终运行在同一台机器上，通常本地会话存储就够了。

<div id="工作方式" />

## 工作方式

```text theme={null}
首次请求
  query()
    -> qodercli 把会话写到本地
    -> SDK 把新增的会话 entry 镜像到你的存储

后续请求，可能在另一台机器上
  query({ resume: sessionId })
    -> SDK 从你的存储读取会话历史
    -> qodercli 继续该会话
```

这种设计带来两个特性：

* **写入是尽力而为的。** SDK 在后台镜像 entry。写入失败会被上报，但绝不会中断正在进行的对话；参见下文的[运维](#运维)。
* **entry 对存储不透明。** 你的存储原样保存并返回 SDK 定义的 transcript entry。应用不需要理解 qodercli 的本地文件格式。

<div id="快速开始" />

## 快速开始

`InMemorySessionStore` 只把数据保存在当前进程中。在接入共享后端之前，用它来验证接线是否正确——即 query 确实会写入 store 并能从中恢复。它无法演示跨机器恢复，因为进程退出后数据就没了；跨机器场景需要实现真正的存储，见[实现存储](#实现存储)。

```typescript theme={null}
import {
  InMemorySessionStore,
  qodercliAuth,
  query,
} from '@qoder-ai/qoder-agent-sdk';

const store = new InMemorySessionStore();
const options = {
  auth: qodercliAuth(),
  cwd: '/path/to/project',
  sessionStore: store,
};

// 首次 query：运行并记录 session ID。
let sessionId: string | undefined;
for await (const message of query({
  prompt: '记住数字 42。',
  options,
})) {
  if (message.type === 'result') sessionId = message.session_id;
}
if (!sessionId) throw new Error('首次 query 未返回 session ID。');

// 后续 query：从 store 恢复，模型能回忆起之前的上下文。
for await (const message of query({
  prompt: '我让你记住的数字是多少？',
  options: { ...options, resume: sessionId },
})) {
  if (message.type === 'result') console.log(message.result); // -> "42"
}
```

所有机器必须使用相同的 `cwd`，SDK 才能把请求识别为同一个项目。

<div id="在应用中使用" />

## 在应用中使用

每次需要镜像或恢复会话的 query，都在 `options.sessionStore` 中传入 store，然后选择如何指定会话：

* **已知 session ID** —— 传入 `resume: sessionId`。
* **该项目最近的会话** —— 传入 `continue: true`。这要求 store 实现 `listSessions`。

<div id="管理外置会话" />

### 管理外置会话

会话管理函数接受同一个 `sessionStore` 选项，并在 store 而不是本地文件上操作：

```typescript theme={null}
import {
  listSessions,
  getSessionInfo,
  getSessionMessages,
  listSubagents,
  getSubagentMessages,
  renameSession,
  tagSession,
  forkSession,
  deleteSession,
} from '@qoder-ai/qoder-agent-sdk';

const project = { dir: '/path/to/project', sessionStore: store };
const sessions = await listSessions(project);
const messages = await getSessionMessages(sessionId, project);
await renameSession(sessionId, 'Investigation #1', project);
await deleteSession(sessionId, project);
```

`listSessions` 要求 store 实现 `listSessions`；`listSubagents` 要求 `listSubkeys`；`deleteSession` 要求 `delete`。如果实现了 `listSubkeys`，`getSubagentMessages` 也会使用它。

要把已有的本地会话复制进 store——例如迁移一台此前未启用外置存储的机器——使用 `importSessionToStore`：

```typescript theme={null}
import { importSessionToStore } from '@qoder-ai/qoder-agent-sdk';

await importSessionToStore(sessionId, store, { dir: '/path/to/project' });
```

<div id="实现存储" />

## 实现存储

SDK 不提供可直接用于生产的存储实现；应用需要针对自己选择的共享存储实现 `SessionStore` 接口。Redis 和 PostgreSQL 的可运行参考实现展示了具体用法，见 [TypeScript 示例](https://github.com/QoderAI/qoder-agent-sdk-samples/tree/main/typescript/external-session-storage)或 [Python 示例](https://github.com/QoderAI/qoder-agent-sdk-samples/tree/main/python/external-session-storage)。它们是起点，而非可直接用于生产的实现。

```typescript theme={null}
type SessionKey = {
  projectKey: string; // 由 cwd 推导，用于标识项目
  sessionId: string; // 会话 UUID
  subpath?: string; // 子代理 transcript 才有，例如 "subagents/agent-<id>"
};

type SessionStoreEntry = {
  type: string;
  uuid?: string;
  timestamp?: string;
  [key: string]: unknown; // 不透明的 transcript 行——原样保存并返回
};

interface SessionStore {
  // 必需
  append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void>;
  load(key: SessionKey): Promise<SessionStoreEntry[] | null>;
  // 可选——只实现你需要的能力
  listSessions?(
    projectKey: string,
  ): Promise<Array<{ sessionId: string; mtime: number }>>;
  delete?(key: SessionKey): Promise<void>;
  listSubkeys?(key: Omit<SessionKey, 'subpath'>): Promise<string[]>;
}
```

一个 `SessionKey` 标识一份 transcript。主会话没有 `subpath`；每份子代理 transcript 复用相同的 `projectKey` 和 `sessionId`，但带有不同的 `subpath`。把 key 和 entry 当作不透明数据——原样保存并返回，不要解析其中的消息内容。

两个必需方法提供保存与恢复。每个可选方法解锁一项能力：

| 方法              | 解锁的能力                            |
| --------------- | -------------------------------- |
| `append`、`load` | 镜像会话并按 ID 恢复 —— 必需               |
| `listSessions`  | 用 `continue: true` 恢复最近会话；列出已存会话 |
| `delete`        | 从 store 删除会话                     |
| `listSubkeys`   | 完整恢复并查看子代理 transcript            |

<div id="实现检查清单" />

### 实现检查清单

* **同一个 key 保持追加顺序**，`load` 返回完整历史。回放依赖顺序。
* **隔离各 key。** 绝不要把一个 key 的 entry 返回到另一个 key 下。
* **让 `append` 幂等。** SDK 可能用相同的 entry 重试失败的写入，因此重试不能重复写入历史。
* **`listSessions().mtime` 返回 Unix 毫秒时间戳**，并且只返回主会话，即不带 `subpath` 的会话。
* **级联删除。** 删除主会话时，必须同时删除它的子代理 transcript。
* **`listSubkeys` 只返回相对标识**——不要返回绝对路径，也不要返回包含 `.` 或 `..` 的路径。这些会成为存储键，路径穿越片段会让 transcript 逃出它所在的命名空间。
* **串行化并发写入。** 如果同一会话可能被多个进程写入，在存储层串行化这些写入。

用 SDK 仓库中的 `SessionStore` conformance 测试检查这些通用行为，并为具体后端补充并发和重试测试。连接管理、权限、加密、备份、迁移和数据保留仍由应用负责。

<div id="运维" />

## 运维

**失败处理。** 外置写入失败不会中断当前对话。最后一次重试失败后，SDK 会发出 `system/mirror_error` 消息。对镜像完整性有要求时应监控它——即使某次写入没成功，对话仍可能成功完成。

**调优。**

* 外置读取默认最多等待 60 秒。用 `loadTimeoutMs` 调整。
* `sessionStoreFlush` 默认为 `'batched'`。设为 `'eager'` 会更早镜像 entry，代价是更多的存储请求。

**约束。**

* 显式指定的会话在 store 中不存在时，SDK 仍可回退到本机上的同 ID 会话。
* `sessionStore` 不能与 `persistSession: false`、文件 checkpoint、自定义 transport provider 或实验性的 Cloud Agent runtime 同时使用。
* store 在内置的 Process 和 Worker transport 上受支持。
* store 只保存会话历史，不保存鉴权状态、应用配置、文件 checkpoint 或数据保留策略。
