Skip to content

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

Description

@wutongyuonce

TL;DR

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

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

原来的 Maka 设计与本次变化

原有 Desktop:真正导入

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

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

原有 TUI:摘要交接

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

这不是真正导入,只是把摘要交给一个新 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 消息写回外部来源。
  • 不比较来源是否变化,不新增 sourceUpdatedAtsourceRevision 或 freshness 状态机。
  • 不在已有 Maka Session 上执行刷新、去重或历史合并。
  • 不把外部工具调用和结果当成 Maka 当前运行时的执行事实。
  • 不新增 importer、external message IR、digest projection framework 或第二个 OpenCode reader。
  • 不迁移以前由 digest handoff 创建的普通 Maka Session。

统一后的完整流程

用户打开 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

第一次导入外部 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 中记录:

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

Host 的 SQLite session_metadata 同时保存可查询的 external_adapter_idexternal_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。

同一导入仍在执行时再次收到相同请求
  -> 返回同一个 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 如何共享

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 不读客户端文件;
  • <foreign-session-digest> 不再有生产调用者。

被否决的方案

  • 保留 feat(storage): scan OpenCode sessions in foreign-session store #5055 的独立 OpenCode SQLite reader:重复来源格式权威,而且已经发生规则漂移。
  • 保留 feat(storage): scan OpenCode sessions in the foreign-session store #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 修补。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions