apache / apache/maka

feat(cli): 统一 TUI 与 Desktop 的外部会话导入流程

Open
#5,053 3 comments 0 reactions 1 assignee Claimed by @ggbdpq View on GitHub
Dominant language
TypeScript
Stars
5.4k
Forks
502
Avg merge
1d 2h
Merged PRs (30d)
715

Description

## TL;DR
Maka 目前有两套外部会话接续方式:Desktop App 会把外部历史真正导入为原生 Maka Session,TUI 则创建一个空 Session,再把旧会话摘要作为隐藏 handoff prompt 发给模型。

本 Feature 将两条链路统一:TUI 与 Desktop 都通过现有 Host catalog/import 选择并导入一个具体的外部 Session。每次显式导入都创建一份独立的原生 Maka Session 快照,不自动调用模型,不覆盖或合并以前的导入。

## 原来的 Maka 设计与本次变化

### 原有 Desktop:真正导入

```text
外部会话存储
-> ExternalSessionAdapter
-> Maka StoredMessage[]
-> ExternalSessionImporter
-> 原生 Maka Session
-> Desktop 打开该 Session
```

OpenCode、Claude Code、Codex 已有各自的 Adapter。Host 已经提供 source discovery、catalog query、import、原子持久化、暂存恢复和导入状态查询。

### 原有 TUI:摘要交接

```text
本地 foreign-session scanner
-> 读取 Claude Code / Codex 会话
-> 提取用户文字、助手文字和文件路径摘要
-> 创建空 Maka Session
-> 把 作为第一条隐藏提示发给模型
```

这不是真正导入,只是把摘要交给一个新 Agent。#5055 和 #5125 最初都在尝试把 OpenCode 接进这条旧路径,因此同一个 OpenCode 数据需要被多套代码解释,TUI 与 Desktop 接续同一个来源时也会得到不同结果。

### 本次统一后的区别

| 维度 | 原有 TUI | 原有 Desktop | 本次设计 |
| --- | --- | --- | --- |
| 来源读取 | TUI 本地 scanner | Host 中的 Adapter | 两端统一使用 Host Adapter |
| 导入结果 | 空 Session + 摘要 prompt | 原生 Maka Session | 原生 Maka Session |
| 是否自动调用模型 | 是,发送 handoff | 否 | 否 |
| 历史内容 | 有损摘要 | canonical `StoredMessage[]` | canonical `StoredMessage[]` |
| 来源格式权威 | scanner 与 Adapter 并存 | Adapter | 只有 Adapter |
| 重复导入 | 每次生成摘要会话 | 每次生成独立副本 | 每次显式导入生成独立副本 |
| TUI/App 是否共享 | 否 | 仅 Desktop 使用 | 同 Host/Profile 下共享 |

核心不是“给 TUI scanner 增加 OpenCode”,而是删除 TUI 的特殊导入语义,让两个界面复用 Maka 已有的 Host 原生导入设计。

## 目标

1. TUI 与 Desktop 使用同一套外部 Session catalog/import。
2. 用户选择某个来源下的某个 Session;只发现和列出,不自动批量导入。
3. 每次显式导入都创建新的原生 Maka Session,导入本身不触发模型请求。
4. Adapter 是唯一的来源格式权威;Host 是唯一的导入、原子性、恢复和状态权威。
5. 导入是一次性快照,不持续同步,不覆盖或合并旧 Session。
6. 同一 Runtime Host/Profile 下,TUI 与 Desktop 看到同一份 Maka Session 和导入状态。
7. 外部存储始终只读;读取有界;不能发布空的、部分的或伪装成完整的导入。

## 非目标

- 不同步外部 Session 后续新增的消息,也不把 Maka 消息写回外部来源。
- 不比较来源是否变化,不新增 `sourceUpdatedAt`、`sourceRevision` 或 freshness 状态机。
- 不在已有 Maka Session 上执行刷新、去重或历史合并。
- 不把外部工具调用和结果当成 Maka 当前运行时的执行事实。
- 不新增 importer、external message IR、digest projection framework 或第二个 OpenCode reader。
- 不迁移以前由 digest handoff 创建的普通 Maka Session。

## 统一后的完整流程

```text
用户打开 TUI / Desktop 的 Session 选择界面
-> 当前 Runtime Host 查询可用来源
-> 用户选择某个来源的某个 Session
-> 界面调用 external-session.import
-> Host 调用对应 ExternalSessionAdapter
-> Adapter 只读地读取一致快照并转换为 StoredMessage[]
-> ExternalSessionImporter 创建暂存的原生 Maka Session
-> Host 将导入历史物化为 Runtime Ledger
-> 成功后发布并返回 Maka Session id
-> TUI / Desktop 打开返回的 Session
-> 用户下一次主动发送消息时,模型才基于导入历史继续
```

## 用户可见行为

### 1. 每次只导入一个明确的外部 Session

Host 可以检测到 OpenCode、Claude Code、Codex,但检测来源不等于导入。一次请求始终携带明确的 `(adapterId, sourceSessionId)`,只导入用户选择的那一个 Session。

界面可以先选来源再选 Session,也可以合并展示;这只是展示方式,不改变单 Session 导入语义。

### 2. 显式导入永远新建独立 Session

```text
第一次导入外部 Session S -> Maka Session A
第二次导入外部 Session S -> Maka Session B
第三次导入外部 Session S -> Maka Session C
```

来源有没有变化都不影响规则。A、B、C 是互相独立的一次性快照,永不互相覆盖或合并。这样用户可以从同一份外部历史建立不同工作分支、使用不同模型或配置,也不会因为外部后来新增消息而改写已经在 Maka 中继续过的历史。

选择现有 Maka Session 仍然只是打开它;选择外部 Session 并执行导入则一定创建新 Session。Host 不会把 import 隐式改成 open。

### 3. 导入来源记录如何保存

不新增单独的“导入记录文档”或累计计数器。每个导入后的 Maka Session 在自己的持久化 metadata 中记录:

```ts
externalOrigin: {
adapterId: string;
sourceSessionId: string;
}
```

Host 的 SQLite `session_metadata` 同时保存可查询的 `external_adapter_id` 和 `external_source_session_id`。catalog 查询时,根据当前仍存在且已发布的 Session 动态计算:

- `importedCount`:这个外部 Session 当前还有多少个 Maka 导入副本;
- `recentSessionIds`:最近的若干导入副本;
- `isImporting`:当前是否正在导入。

删除某个 Maka Session 会删除它的 metadata 行,因此计数和 id 列表会在下次查询时自然减少;删除全部副本后回到 0。tombstone 只用于清理和防止 Session id 复用,不继续算作导入副本。归档 Session 仍然存在,因此当前设计继续计入。

这些查询结果可以用于展示“已导入 2 次”或导航到已有副本,但不改变 import 的结果。导入正确性不依赖计数。

### 4. 只合并仍在进行的重复请求

Host 继续保留现有的内存 `importsInFlight`:以 `(adapterId, sourceSessionId)` 为 key 保存正在执行的 import Promise。

```text
同一导入仍在执行时再次收到相同请求
-> 返回同一个 Promise
-> 两个调用方得到同一个 Maka Session
-> 只创建一次

第一次导入已经完成后再次请求
-> 这是新的明确意图
-> 创建新的独立 Maka Session
```

它处理的是双击、页面关闭后立即重进、Desktop 多窗口、TUI 与 Desktop 同时请求、客户端在结果返回前重试等场景。它只协调同一个 Host 进程;不同 Host/Profile 本来就是不同的 Session 空间。

### 5. 导入历史如何进入后续模型上下文

沿用 Maka 已有的 `conversation_text`:

- 进入模型上下文:真正的人类 user text、非空 assistant 可见文字;
- 不进入模型上下文:thinking、tool call、tool result、permission、system note;
- OpenCode `synthetic: true` user text 必须在 Adapter 仍持有 raw part provenance 时排除,因为它可能是自动 summary instruction 或 MCP resource 内容;
- synthetic-only 消息不能创建空 turn;
- 不生成摘要,不拼接 handoff prompt,不把外部 tool output 伪装成用户输入。

外部工具协议可以作为转换时的来源事实,但不是 Maka 下一次 provider replay 的执行权威。

### 6. TUI 与 Desktop 如何共享

```text
TUI ---------\
-> 同一个 Runtime Host -> 同一个 Maka Session Store
Desktop App -/
```

连接同一个 Host/Profile 时,两端查询同一份 catalog;一端导入的 Maka Session 会出现在另一端的 Session 列表;两端可以打开同一个 Maka Session,看到同一份历史;同时发起同源导入时由 Host 合并进行中的操作。

连接不同 Host/Profile 时,来源存储和 Maka Session Store 不同,导入结果不共享。远程 Host 场景必须读取远程 Host 上的来源,TUI 不得回退到客户端本地文件。

## 模块责任与接口接缝

### ExternalSessionAdapter:唯一的来源格式权威

Adapter 负责:

- 检测来源、查询和过滤来源 Session;
- 解释来源 schema、消息、part、父子关系和归档字段;
- 只读地读取一个一致快照;
- 在 provenance 仍存在时排除 synthetic/不可信内容;
- 在来源数据进入 JS 内存前执行行数和字节上限;
- 输出 canonical `StoredMessage[]`。

OpenCode 格式变化时只修改 `OpenCodeSessionAdapter`,不修改 TUI、Desktop 或 Host importer。

### Host catalog/import:唯一的导入与状态权威

Host 负责:

- source discovery、catalog query、分页和稳定错误码;
- 从持久化 `externalOrigin` 动态查询当前导入副本;
- 用内存 `importsInFlight` 合并进行中的同源请求;
- 选择 Adapter、目标模型和 Session 配置;
- canonical validation、原子提交、暂存、恢复与发布;
- 返回可直接打开的 Maka Session id。

### TUI / Desktop:只负责选择、导入和打开

界面只需要知道有哪些来源和 Session、当前是否正在导入、导入成功后打开哪个 Maka Session。界面不得理解 OpenCode SQLite、判断 synthetic、比较来源版本或合并历史。

## OpenCode 读取、安全与完整性

### 完整或拒绝

推荐规则:一次导入要么完整成功,要么明确拒绝。不能静默发布任意前缀、尾部、空历史,或者在丢行后仍标成完整。

`OpenCodeSessionAdapter.readSession()` 应在同一个只读 SQLite snapshot 中:

1. 只打开配置目录下经过 realpath confinement 的 `opencode.db`;
2. 只使用固定 SQL identifier 和 bound values;
3. 验证必要表/列、Session id 和 root/child 关系;无法证明不是 child 时 fail closed;
4. 在 SELECT 返回 payload 前,以 SQLite byte length 预检所有会进入内存的来源字段;
5. 预检本身最多检查 `rowLimit + 1` 行,不能为了判断超限而无界遍历;
6. 单字段、累计字节或行数超限时,在 materialize oversized payload 前拒绝;
7. 预检通过后才完整读取和转换同一 snapshot;
8. malformed JSON、schema 不兼容、snapshot 不一致或超限都不得创建部分 Session。

建议初始上限为 64 MiB raw source payload(与 Claude full-import ceiling 对齐)和 250,000 行(与 Codex 已有 converted-message 量级对齐)。使用命名常量,并允许测试注入更小上限。

超限、schema、malformed 等错误映射为稳定的 `source_unreadable` 和脱敏文案,不能假装成 `not_found`。#5055 已复现的“2048 条 message 用完预算,part 一条也没读,却成功返回空摘要”会随 digest 删除,并由完整或拒绝契约覆盖。

### Catalog 规则

- 来源不存在:`detect()` 为 false,不显示该来源;
- 来源存在但 unreadable/schema incompatible:只让该来源查询失败,不隐藏 Maka Sessions 或其他来源;
- child 永远排除;archived 默认排除,显式 `includeArchived` 才包含;
- `NULL time_updated` 回退到 `time_created`,按最新优先;
- cwd 使用共享 external-session path equality/search;
- id/title/cwd 在进入 JS 前有界;
- 复用 Host protocol 已有 page 和 encoded-result 上限,不建立 TUI 专用 catalog 类型。

旧 scanner 专用的 `MAKA_IMPORT_*` flags 随 scanner 删除。将来若需禁用来源,应在 Adapter registry/Host policy 层统一实现,不能让 TUI 与 Desktop 使用不同开关。

## 原子性和失败语义

保留 Host 当前行为:

- Adapter 读取和转换完成前不创建 Session;
- `createImportedSession(...)` 在写入前用 canonical decoder 验证全部 `StoredMessage`;
- header、messages、catalog projection 和 `externalOrigin` 原子提交;
- 导入 Session 先以 `transcriptLedgerVersion: 0` 暂存,Runtime Ledger 完成后才发布为版本 `1`;
- preparation 失败删除 staging,重启后恢复未完成的确定性物化;
- 同来源并发导入 coalesce,完成后的显式重复导入创建独立副本;
- `commit_outcome_unknown` 请求 Host drain,并通过 catalog 对账,禁止盲目重试;
- 不写入、迁移、重命名外部数据库,也不以写权限打开。

TUI 额外遵守:一个来源失败不影响其他来源和 Maka Sessions;import 成功但 switch 失败时提示已导入的 Session id,不自动重试;`commit_outcome_unknown` 时刷新 catalog,能确认新 Session 才打开,否则提示用户检查 Session 列表;active turn 期间不提供外部导入。

## 实现计划

1. 确认文末三项 maintainer/reviewer 决策。
2. 加固 `OpenCodeSessionAdapter`:confined read-only snapshot、有界预检、root-session 验证、synthetic provenance 过滤和明确失败。
3. 保持 Adapter 输出为 `ExternalMakaSession` / `StoredMessage[]`,不增加 `readSessionBounded()` 或 digest-only 类型。
4. 在 runtime-host TUI interface 中复用已有 external-session Host operations。
5. 用 Host catalog/import/switch 替换 TUI 的本地 foreign scan 和 `importForeignSession()` handoff。
6. 保留现有 `externalOrigin`、动态 import lookup 和 `importsInFlight`;不增加版本字段、freshness 状态或独立导入表。
7. 删除 `createForeignSessionStore()` 的生产调用、`MakaForeignSessionReader`、digest handoff builder、scanner-only flags/constants 和失去调用者的测试。
8. 只保留或移动真实 Adapter 仍使用的 parser/sanitizer,不为少移动 helper 而保留死亡 interface。
9. 不做数据迁移;旧 digest Session 保持普通 Session,既有原生导入继续通过 `externalOrigin` 被查询。

## 验收测试

### OpenCode Adapter

- human user text 保留;synthetic summary instruction 和 MCP-resource synthetic text 排除;
- synthetic-only 消息不创建空 turn;assistant 可见文字保留;模型历史不包含 thinking/tool protocol;
- root 可列出/导入,child 被排除/拒绝;
- archived 默认排除且显式包含有效;
- `NULL time_updated` 回退、cwd normalization、最新优先排序正确;
- 缺少必要 schema、malformed JSON、snapshot 不一致时拒绝完整导入;
- UTF-8 多字节按 bytes 计数;单个超大字段、累计 byte cap、row cap 在 materialization 前拒绝;
- 2048-message 回归不能产生空的成功导入;读取前后来源数据库 byte-identical。

### Host importer/catalog

- invalid converted message 不发布 Session;
- staging 在 Runtime Ledger 完成前不可用,失败删除,重启可恢复;
- `externalOrigin` 持久化,历史按 `conversation_text` 物化;
- 当前只有 A、B 两个导入副本时 lookup 返回 2 和对应 ids;删除 A 后返回 1,全部删除后返回 0;归档副本仍计入;
- 同来源并发请求只创建一个 Session,两个调用方得到相同结果;
- 前一次完成后的再次导入创建不同 Session id;
- unknown commit outcome 通过 catalog 对账,不盲目重试。

### TUI / Desktop

- TUI 使用 Host source/catalog/import,不调用本地 scanner;
- 只发现和列出,不批量导入;一个来源失败不影响其他来源和 Maka Sessions;
- 选择外部 Session 创建新的原生 Maka Session,不自动调用模型、不创建 handoff message;
- 同一外部 Session 每次完成后的显式导入都创建独立副本;
- switch failure 不触发二次导入;unknown outcome 刷新 catalog 并阻止盲目重试;
- active turn 期间不可导入;
- 同 Host/Profile 下两端能看到同一导入结果,不同 Host/Profile 隔离;远程 Host 不读客户端文件;
- `` 不再有生产调用者。

## 被否决的方案

- **保留 #5055 的独立 OpenCode SQLite reader**:重复来源格式权威,而且已经发生规则漂移。
- **保留 #5125 的 digest-specific bounded projection**:仍保留两种 Resume 语义。
- **增加来源版本和 freshness 状态机**:会扩大持久化、protocol、Host 与两个界面的接口,但不影响“显式导入必定新建”的正确性。
- **自动打开旧导入或刷新旧 Session**:让 import 的结果依赖隐式状态,并引入历史合并问题。
- **静默导入一个“有用的尾部”**:产生不完整历史,需要另一套 UX/provenance 契约。
- **新增 external message model 或第二个 importer**:现有 Adapter、canonical `StoredMessage`、Importer 和 staging 已提供正确接缝。
- **把外部 tool protocol replay 给当前 provider**:另一个运行时的工具事实不是 Maka 当前执行权威。
- **保留 TUI-only source flags**:同一 Adapter 在两个界面会表现成不同产品。

## 需要 maintainer/reviewer 确认

### A. 超限策略和初始上限

来源超过支持边界时,是完整拒绝,还是创建带明确标记的部分 Session?

**推荐:**完整或拒绝;初始使用 64 MiB raw payload、250,000 行。只有真实需求证明必须支持部分历史后,再单独设计 partial-session UX。

### B. Catalog 覆盖范围

TUI 应使用 Host 的完整分页目录,还是保留旧的“当前 workspace、最近 30 天、最多 50 条”展示?

**推荐:**使用 Host catalog,当前 workspace 作为初始筛选并按需分页,不增加 TUI-only 的 30 天限制。如果 Adapter 在 Host 分页前 materialize 过多数据,应修复共享 Adapter/catalog interface,而不是只在 TUI 隐藏旧 Session。

### C. 是否允许我重新开一个整合 PR

是否允许我新开一个 PR 实现上面的统一方案?我会延续 #5055 和 #5125 中仍符合目标架构的部分,包括已复现约束、回归测试和有价值的来源读取加固,同时删除重复 OpenCode reader 和 digest-specific 流程。

**推荐:**允许新开一个干净、范围明确的 PR,避免继续把任一现有 PR 改造成另一种架构。新 PR 建立后,我会把 #5055 标记为已被替代;#5125 如何关闭或复用,由 maintainer 与其作者决定,避免重复劳动。

A-C 确认后,其余设计分支已经收敛,可以开始实现,不再进行下一轮 digest-specific 修补。

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.