73 Commits
Author SHA1 Message Date
ypshidifu f49ff8ef36 tui.js 消除版本假象:toast 文案 v10 改 v12 + v12 覆盖 tui-plugins 旧版
- 真相:WSL toast 显示 v10 是 v12 文件里硬编码忘改的旧文案(v12 独有的
  「操作未生效:DB 写未生效」失败 toast 已在 WSL 出现 = v12 实际在跑)
- plugins/oc-session-all/tui.js(54KB v12) 覆盖 tui-plugins/oc-session-all.js(29KB 真 v10):
  tui.json 注册路径指向后者,此前是隐患(按注册路径加载会拿到 v10)
- 三份(plugins / tui-plugins / src 真源)sha 72f173454def 一致
2026-10-20 23:31:50 +08:00
ypshidifu a41cf92379 session-sync 洗白扩展到 session/session_v2.directory:OC 2.x location 显示读会话表自身 directory 列
- Win DB 实锤:目标会话 ses_eb074c0daffe 的 session_v2.directory = C:/Users/ypshidifu(截图报错来源)
- FIX_WORKTREE_SRC 扩展:project.worktree + session.directory + session_v2.directory 三表全洗(幂等,每次无条件跑)
- 推送侧转换统一 _conv_win_path,session/session_v2 行的 directory 列同步转换
2026-10-20 23:20:44 +08:00
ypshidifu 546fc07779 session-sync 修存量 worktree 洗白:INSERT OR IGNORE 洗不掉 WSL 已存在 project 行
- 新增 FIX_WORKTREE_SRC:每次同步无条件把 WSL project 表存量 C:/Users/ypshidifu
  与 C://Users//ypshidifu 前缀 worktree UPDATE 成 /root(幂等)
- 修 WSL 侧 Session location unavailable(截图实锤:推送侧转换只对新插入行生效,
  存量行永远不更新且无 missing/stale 时 import 根本不执行)
2026-10-20 23:13:30 +08:00
ypshidifu 6b65ec50e7 session-sync: project.worktree 路径转 WSL(修Session location unavailable);model-sync 已补 db-exec/tui.json 2026-10-20 22:12:17 +08:00
ypshidifu f2b7303cd2 model-sync: 补同步 model-manager-db-exec.py(修WSL停用失效) + tui.json/tui-plugins(修/mcp-off缺失),路径转换覆盖 file:// 前缀 2026-10-20 21:59:20 +08:00
ypshidifu 3c768343e4 model-sync: 同步 opencode.json(provider+MCP) 与 mcpcfg 路径转 WSL,修 mcpcfg 正则转义 bug 2026-10-20 21:16:32 +08:00
ypshidifu afdafb0ed1 model-sync: 加 opencode.json.2 provider 同步;session-sync: 双表驱动 2026-10-20 20:49:29 +08:00
ypshidifu 5cd94dc4e1 修 oc-model-sync:NOT NULL 动态补列 + 推 4 个插件状态文件
WSL import 因 kv.time_created NOT NULL 回滚,catalog/credential 全没写入:
- kv/credential 改按 WSL 实际表列 PRAGMA 动态补全默认值(time_* 补 now,active 补 0,
  id 缺时生成稳定 win_xxx,label NOT NULL 兜底),杜绝 NOT NULL 约束失败
- 新增 4 个 model-manager 状态文件进同步(cfg/removed/disabled/stoplist.bak),
  让 WSL 侧感知 Win 已停用 xiaomi、已删 Agnes;pristine 不推(WSL 自补)
- 同步 runtime -> tools/ 真源
2026-10-20 19:17:22 +08:00
ypshidifu f7688bf27e 重定位 WSL 模型同步:Win opencode.db 真源(catalog+credential)推 WSL DB 覆盖式对齐,修 xiaomi 挂错树/旧模型残留 2026-10-20 18:35:46 +08:00
ypshidifu 884d53b619 修「重载了但模型还在」:热重载/启动时从最近使用与收藏历史(model.json)剔除停用条目,toast同步显示清理条数 2026-10-20 10:32:29 +08:00
ypshidifu b911a43940 修复已停用厂商找回交互:选中先进确认层(可看出厂模型/返回),点恢复才入队reset 2026-10-20 10:08:58 +08:00
ypshidifu 19e16a42e0 flush轮询识别失败标记:DB写未生效时提示失败,不再假报已重载 2026-10-20 09:21:41 +08:00
ypshidifu 874e69fb14 tui.js:模型管理面板改状态机,每层带「← 返回上级」;恢复出厂设置=删除厂商并二次确认 2026-10-19 22:29:27 +08:00
ypshidifu cd25c0a3d0 tui.js:进厂补恢复出厂(格式化)入口、整厂停用改单条 disable_all、flush 改常驻轮询热重载 2026-10-19 18:38:40 +08:00
ypshidifu 0001fc7d64 tui.js:模型操作入队后等 daemon flush 落盘再执行 location.reload 热重载 2026-10-19 16:59:21 +08:00
ypshidifu 7ab1b9a9b0 tui.js:/manage 精确匹配替换前缀匹配,选厂后加单模型/整厂粒度层 2026-10-19 12:34:02 +08:00
ypshidifu 38c2b20e3d tui.js:修 /manage catalog 二次解包——models-dev:catalog 外层是 {updatedAt,digest,body},body 才是 provider JSON;此前只解一层只拿到3个key致前缀匹配恒空(日志实证 catalogProviders=3 命中[]) 2026-10-19 12:18:37 +08:00
ypshidifu aa9b3281f1 tui.js:/manage 加全链路诊断日志(来源A config、来源B credentials+命中、最终列表),便于实证排查内置 provider 是否出现 2026-10-19 12:02:56 +08:00
youpaishidifu 8e07bd8af8 tui.js:/manage 来源B改读 credential 表(active=1 的 integration_id)前缀匹配 catalog,能管到内置 /connect 的 provider(如 Xiaomi 全家族) 2026-10-19 11:46:33 +08:00
youpaishidifu 2f64df6a9b tui.js:/manage 来源B改只列已连接 catalog provider(env 任一 key 在 process.env),不再全量拉232个 2026-10-19 11:01:11 +08:00
youpaishidifu 90eb09b2a4 tui.js:/manage 改为只显示已连接 provider(读 opencode.json provider 字段),不再拉全量 232 个 2026-10-19 10:48:58 +08:00
youpaishidifu fd8cfa8887 tui.js:修 /manage 面板 dialog.select options 字段 label→title(label 不识别→undefined.length 崩 OC) 2026-10-19 10:42:30 +08:00
youpaishidifu 043fd55ae1 tui.js:修 mmReadCatalog Windows GBK 编码坑(sys.stdout.buffer.write + PYTHONUTF8) 2026-10-19 10:36:51 +08:00
ypshidifu 0d2d1144eb 补全各分支的基础情况 2026-10-19 10:29:40 +08:00
youpaishidifu 250e71c48c tui.js:修 /manage mmReadCatalog Python 单行语法错误(if row: import sys 非法)→ 提前 import sys 2026-10-19 10:25:32 +08:00
youpaishidifu b7537988f9 tui.js:新增 /manage 模型管理面板(/manage 别名 managent);sync_wsl.py 加 model-manager 项;.gitignore 拉黑 model-manager/ 2026-10-19 10:13:28 +08:00
ypshidifu 9dfe04f221 main: 移除 oc-retry-guard 跟踪,拉黑该子目录(专属分支独立维护)
- 移除 oc-retry-guard/oc-retry-guard.ts 的 main 跟踪(该插件只在 oc-retry-guard 分支维护)
- .gitignore 增加 oc-retry-guard/ 拉黑
- 保留 tui.js v11 排版改动 + sync_wsl.py ITEMS 增加 oc-retry-guard 项
2026-10-19 09:30:34 +08:00
ypshidifu fcf188bf28 oc-retry-guard 插件(严重错误自动重试·事不过三2分钟周期)+ 会话面板 v11 排版(日期降序+sessionID)
- 新增 oc-retry-guard.ts:http.response 钩子读 body 识别 finish reason 类错误,
  命中后按 2分钟窗口/最多3次 计数,自动用缓存 body 追加 nudge 重发同端点并替换 response;
  超 3 次或超 120s 自动归零,事不过三后 STOP。
- tui.js 会话选择器 v11:从按项目分组改为全局按 time.updated 降序,title 末尾加 [sessionID前8位]。
- sync_wsl.py ITEMS 增加 oc-retry-guard 同步项。
2026-10-19 09:24:20 +08:00
ypshidifu 2f91a612bb 新增 WSL 插件批量同步工具 tools/sync_wsl.py;TUI 插件为 mcpcfg.txt 增加实时监测与丢失自动重建防御 2026-10-12 19:42:44 +08:00
ypshidifu ee4be54d0f gitignore:忽略compaction-cn独立仓库,补忽略测试产物js 2026-09-25 15:40:26 +08:00
ypshidifu 4431ed8335 移除调试文件和目录 2026-09-24 12:43:18 +08:00
ypshidifu 1ba3ad6bd3 TUI插件MCP文案去误导:reload成功即报成功,推送失败仅留log不进结果toast 2026-09-24 12:39:47 +08:00
ypshidifu c593f94a5e README:补回skill推拉章节(并行编辑覆盖丢失回补) 2026-09-23 19:55:48 +08:00
ypshidifu 6215144fb5 README:新增skill推拉新流程章节,ai-mcp指路双导出配方三态实验结论 2026-09-23 19:54:31 +08:00
ypshidifu 0f099c88d0 TUI插件v10:MCP推送兜底改GET+merge补shell三跳,README同步安装路径与快捷键 2026-09-23 17:50:26 +08:00
ypshidifu 6e0ed1743a TUI插件v9:修复dirname导入缺失致自愈静默失效,新增reload前mcp配置推送兜底(双保险)
- 补回 node:path 的 dirname 导入:healShadowPath 引用它,缺导入时
  ReferenceError 被自身 catch 吞掉,影子路径自愈静默失效
- 新增 pushMcpConfig:swap+flag 之后、reload 之前把活配置 mcp 推进
  运行中 OC(SDK config.update → body 形参 → experimental 三连),
  推送成功+reload 双保险,失败降级原单通道
- toast 区分双保险/单通道文案;log/toast 版本标识升级 v9
- 单测新增 S0 回归断言:源文件 path 导入必须含 dirname
  (prelude 复制的导入曾掩盖源码缺失,15/15 假绿 -> 16/16)
2026-09-23 15:31:55 +08:00
ypshidifu 20a3b487fd TUI插件:mcpcfg键值化+路径自愈+命令无条件注册+mcp.on.flag防归位;附readMcpCfg单测15例 2026-09-23 13:47:10 +08:00
ypshidifu b65d3d6879 更新主分支的readme.md,说明其它分支的关联关系 2026-09-23 13:17:38 +08:00
ypshidifu 831e3de8d8 MCP 配置对调降级方案:热开关 + reload 热更新 + 命令全面中文化
- 新增 mcpcfg.txt 三行配置(模式/含MCP路径/安静路径),小白只改 txt
- 模式语义:自动=启动归位+命令可用+server插件可切换;手动=仅 /mcpon /mcpoff;关闭=全部禁用
- /mcpon /mcpoff:对调两份配置后调用 location.reload 热重载,MCP 立即生效
- auto 模式启动自动把含 MCP 的活配置归位为安静版(6 秒后重载),保持零 toast 零报错
- enableMcp 加门控:活配置不含 mcp 时跳过 90s 重试 connect,避免 404 空转
- 全部命令标题/描述/dialog/toast 改为简体中文
- .gitignore 排除 ai-mcp/ 与 wsl-round-mcp/ 两个独立子仓库
2026-09-23 12:05:16 +08:00
youpaishidifu ffc0e8d743 更新readme.md的功能介绍 2026-09-23 11:17:19 +08:00
ypshidifu 3c0a8971a6 v7: MCP字段修正——真正生效的开关是disabled不是enabled(会被server schema剥掉);enableMcp改90s重试(启动4~5s location才就绪,过早调用404) 2026-09-23 10:12:41 +08:00
ypshidifu b74d87cfbb v6: MCP改config禁用+插件mcp.connect运行时启用;skill目录5s轮询热加载兜底新增目录盲区 2026-09-23 00:05:43 +08:00
ypshidifu c1cb45bed4 跨平台改造:LOG_DIR改用os.homedir(),v5同步至WSL版(/root/.config/opencode)修复WSL插件failed 2026-09-22 23:00:15 +08:00
ypshidifu d6048da95f v5: 按官方 opencode.btw 模板注册命令 — ui.slot({append:app,render}) + keymap.layer(()=>({mode,commands})),恢复全功能(sessions-all slash命令+ctrl+o+选择器+MCP toast拦截) 2026-09-22 22:21:56 +08:00
ypshidifu 92fee7511f v4: 直接调用 ui.slot(component) 注册组件,在组件渲染阶段调用 keymap.layer 注册命令 2026-09-22 22:04:02 +08:00
ypshidifu 6d59c24126 v3: 尝试通过ui.slot注册组件来获取Keymap Provider上下文,探索在SolidJS渲染阶段注册命令的可行性 2026-09-22 21:50:57 +08:00
ypshidifu f66cde6494 更新探测版插件:探索 keymap.commands 和 dispatch 2026-09-22 21:29:34 +08:00
ypshidifu ba26fd512a 更新探测版插件:添加延时避免 toast 被覆盖 2026-09-22 21:23:54 +08:00
ypshidifu 3d8f093d7e 更新探测版插件:尝试 keymap.layer 注册命令 2026-09-22 21:13:07 +08:00
ypshidifu 459f038f8e 更新探测版插件:将探测结果写入日志文件 2026-09-22 21:02:06 +08:00
ypshidifu fd1a475abc 更新探测版插件:更详细的 context 结构探测 2026-09-22 20:55:42 +08:00
ypshidifu dc1a27e88e 更新tui.js 2026-09-22 20:50:48 +08:00
ypshidifu ec7b461147 更新探测版插件:尝试 context.command.register 注册命令 2026-09-22 20:48:33 +08:00
ypshidifu 39efb3aa0f 更新tui.js 2026-09-22 20:01:50 +08:00
ypshidifu 9f17ac9936 修复: keymap.layer → keymap.registerLayer(方法名修正) 2026-09-22 20:00:28 +08:00
ypshidifu 057e698f22 测试隔离版: keymap.layer commands(无 bindings、无 data.on) 2026-09-22 17:49:05 +08:00
ypshidifu 028af01b3d v2: 加回全部功能 - session列表/快捷键/MCP拦截 2026-09-22 15:58:26 +08:00
ypshidifu 952b31127c v2: 简化 oc-session-all 为最小化版本测试加载 2026-09-22 15:37:54 +08:00
ypshidifu a5a8c5e011 v2: 将 oc-session-all 从 v1 格式转为 v2 Plugin.define 格式
- 使用 import { Plugin } from "@opencode/plugin/tui"
- 使用 Plugin.define({ id, setup(context) }) 格式
- api.* 调用全部替换为 context.*: api.event.on → context.data.on, api.ui.toast → context.ui.toast.show, api.ui.dialog.* → context.ui.dialog.*, api.command.register + api.keymap.registerLayer 合并为 context.keymap.layer({commands, bindings})
- package.json exports 添加 ./tui 入口点,engines.opencode 升级为 >=2.0.0
- 已通过 minimal-v2-test 验证 v2 格式可正确加载
2026-09-22 15:19:14 +08:00
ypshidifu 76d641b17f 删除错误的node_modules目录。 2026-09-22 12:07:43 +08:00
youpaishidifu 6c165dadd5 创建最小化tui.json以便测试各项功能 2026-09-22 11:59:05 +08:00
ypshidifu 47c668f446 添加调试版本插件,修改gitignore允许提交js文件 2026-09-22 11:02:38 +08:00
ypshidifu d201e3bb97 更新tui.json 2026-09-22 10:46:22 +08:00
ypshidifu 6bbbc5ea0b 更新tui.json 2026-09-22 10:27:33 +08:00
ypshidifu 258a40b0d0 整理: 删除冗余的 src/ 目录,统一入口为 tui-plugins/,更新 README 和 package.json 2026-09-21 19:41:05 +08:00
ypshidifu 6c4d97362f 整合: 将 tui-plugins 目录移入项目根目录,统一代码管理 2026-09-21 19:31:08 +08:00
ypshidifu bfe683d7e2 修复: 将TUI插件从plugins/自动发现目录移出,避免server loader报错failed, local
- 将oc-session-all.ts从plugins/移至tui-plugins/目录
- 更新tui.json引用路径为./tui-plugins/oc-session-all.ts
- 移除空的@opencode-ai/plugin/tui类型导入
- 改用api.client.experimental.session.list获取会话
- 优化MCP错误toast拦截:将error替换为info静默提示
- 同步更新源码目录src/index.ts
2026-09-21 18:58:37 +08:00
ypshidifu 37df56c6d9 重写为TUI插件:弹出面板显示所有会话,点击跳转,拦截MCP报错 2026-09-21 18:19:18 +08:00
ypshidifu 289ff13132 修复:将插件从 Tool 改为 Command,支持用户输入 /sessions-all 命令 2026-09-21 17:29:21 +08:00
ypshidifu 45e68bfecf 更新插件:使用 node:sqlite 替代 sqlite3 命令行,提高 Windows 兼容性 2026-09-21 17:21:54 +08:00
ypshidifu 0d19143065 重写插件为V2格式:使用define+id+setup(ctx)+ctx.tool.transform注册工具 2026-09-21 17:04:34 +08:00
ypshidifu 5d43b5ed76 修复:使用 OpenCode 2.x Plugin API,正确导入类型和 tool helper 2026-09-20 23:45:41 +08:00
ypshidifu 02f3ded256 初始化插件:oc-session-all - 显示所有会话记录,绕过目录过滤 2026-09-20 23:35:05 +08:00
13 changed files with 3372 additions and 434 deletions
+22
View File
@@ -0,0 +1,22 @@
node_modules/
!tui-plugins/oc-session-all-debug.js
!tui-plugins/oc-session-all.js
src/
# 独立 git 仓库(分支名 = 文件夹名),各自独立推送到共用远程根目录,不纳入主仓库
ai-mcp/
wsl-round-mcp/
compaction-cn/
oc-retry-guard/
model-manager/
# Python 运行产物
__pycache__/
*.py[cod]
# 备份与本地回滚产物
*.bak-*
skills-backup/
*-debug.js
*-test.js
*.test.mjs
+46 -89
View File
@@ -1,106 +1,63 @@
# compaction-cn — 让 OC 对话压缩摘要输出简体中文
# oc-session-all
OpenCode 原生压缩(compaction)提示词硬编码在 `opencode.exe` 二进制里,摘要默认**纯英文**;
后续任务里模型很容易被前面那段英文摘要带偏。本插件在压缩请求发出前往 `system` 追加
中文输出指令,让摘要**内容用简体中文、标题原样保留英文**(标题不能译,原因见下)。
OpenCode TUI 插件:弹出全项目会话选择器,跨所有项目浏览和切换会话。
分支名:`compaction-cn` | 文件:`compaction-cn.ts`
## 功能
---
- `Ctrl+o` 或 `/sessions-all` 打开可搜索的会话选择面板
- 按项目分组、按更新时间倒序排列
- 过滤掉已归档和子代理会话
- 对调2份`opencode.json`实现拦截mcp报错的toast
- 热更新skill,新的skill丢入目录,插件自动发现并更新
- **MCP 推送兜底(v10)**:`config.get` 取 shell(GET→活文件→null 三级兜底)→
`config.update({config})` → `({body})` → `experimental.config.update` 三跳推进
运行中的 OC,配合 reload 双保险;`/mcpon` `/mcpoff` 可用
## 一、安装
## 安装
源码真源 / 本机生效 / WSL 生效三份同步(MD5 必须一致):
在 `~/.config/opencode/tui.json` 中添加(现役源码是 `.js`,部署副本放 `tui-plugins/` 下):
```
源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts
本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts
WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts
```json
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
"file:///C:/Users/<你>/.config/opencode/tui-plugins/oc-session-all.js"
]
}
```
插件在 **session 初始化时**加载(不是进程启动时),装好后**新建一个会话**即可生效,
已开会话不会热加载。
WSL 侧同理:`file:///root/.config/opencode/tui-plugins/oc-session-all.js`。
源码改动后需同步部署副本(本机 + WSL 两处,MD5 应与源码一致),重启 OpenCode 生效。
---
## 快捷键
## 二、为什么只能这样干预(2026-09-25 实测定稿)
| 快捷键 | 命令 | 说明 |
|--------|------|------|
| `Ctrl+o` | — | 打开会话选择器 |
| `/sessions-all` | `resume-all`, `continue-all` | 斜杠命令 |
| — | `/mcpon` `/mcpoff` | MCP 开关(对调 + 推送兜底 + 热重载) |
| — | 任意位置 `/home` 等 | CLI 菜单(`wsl-round-mcp/oc.py`)跳回顶层,见下节 |
| 候选干预点 | 结论 |
| --- | --- |
| V1 hooks(`default` 对象上的字段,含官方文档的 `experimental.session.compacting`) | ❌ 2.0.15 V2 运行时**零消费者**,探针 24 次 setup 全成功、`A.*` 日志全空;且 named export 返回 `hooks` 对象会被当 cleanup 调用 → `TypeError: Ae is not a function` 整批插件崩 |
| `opencode.config.compaction` | ❌ schema 只有 `{auto, buffer, keep.tokens}`,没有 prompt 覆盖字段 |
| `ctx.session.hook("model.request", cb)` | ❌ payload 是 `[sessionID, agent, model, kind, baseURL, headers]`,**没有 messages**,只能识别(`kind="compaction"`)不能改写内容 |
| `ctx.session.hook("compaction", cb)` | ✅ **唯一可行**。payload = `[sessionID, model, system, messages, options, agent, tools]`,`system` 会带进最终压缩请求 |
## skill 推拉(wsl-round-mcp)
压缩请求时序(二进制逆向):
菜单入口 `oc.py`(任意位置 `/home` 回顶层),skill 管理简化为三动作,**输出路径用户不可选**:
```
compaction hook(prep) → [...messages, 模板 Zt.user(z$(...))] → model.request(kind="compaction")
```
- **推送 push**:只输入源路径(带引号自动剥离成对引号,单侧引号不动)→ 输出固定为 WSL OC skill 目录;
先只读探查同名文件夹:**不存在直接放,存在才询问是否覆盖**(`--force` 跳过确认)。
- **拉取 pull**:只输入 skill 名 → 输出固定为本机 `~/.config/opencode/skills`,同样探查式覆盖。
- **删除 delete**:`--local` 删本机 / 缺省删 WSL,`--yes` 跳过确认;不存在时报错退出码非 0。
- 覆盖与删除统一先移入 `<skills根>/.trash/<时间戳>/` 备份(点前缀目录,OC 不会当技能加载)。
- 推送前过标准 skill 校验(SKILL.md 结构等),不合规拒推。
模板是在钩子**之后**才追加进 messages 的,所以钩子改不到模板本身,只能改 `system`。
### 关键约束:小节标题绝对不能翻译
输出校验 `ww = (e) => e.split("\n").some(a => U$.includes(a.trim()))`,
`U$` 是模板按行过滤出的英文标题行。摘要里必须**整行命中**这些英文标题,否则**压缩直接失败**:
## 分支结构
```
## Objective / ## Requirements / ## Decisions / ## Work State /
### Completed / ### Active / ### Blocked /
## Next Move / ## Relevant Files / ## Important Context
```
所以本插件只加规则「**内容中文、标题原样英文**」,外加路径/代码/URL/报错原文不翻译。
---
## 三、工作方式
`setup(ctx)` 里向 `ctx.session.hook("compaction", cb)` 注册一个回调,往 `g.system`
数组 push 一段带 `<compaction-language>` 标记的文本。带标记是为了**幂等判重**——
多 location、多次 reload 可能重复注册钩子,已注入过就不再加。
日志(不进 stdout,避免污染 TUI):
```
C:\Users\ypshidifu\.config\opencode\compaction-cn.log # Windows
/root/.config/opencode/compaction-cn.log # WSL
```
正常会看到:
```
compaction 钩子注册完成 registration=[...]
已注入中文输出指令 sessionID="ses_xxx" system段=5 messages=N
```
---
## 四、V2 插件契约(与 ai-mcp 同款,勿破坏)
- `default` 导出 **必须**给 `{ id, setup }`(缺了报 `Plugin must export a default definition...`)
- **`setup` 必须返回 void**,绝不抛异常(返回 `hooks`/truthy 对象 → `Ae is not a function` 整批崩)
- named export 用逗号表达式安全形态:`export const X: any = async (_ctx: any) => (await ({} as any), undefined as any)`
- 本插件所有异常都在内部吞掉并落日志,绝不影响同批其它插件
---
## 五、效果对比
压缩前(原生英文输出):
```
## Objective
- Respond to the user with "收到".
```
装插件后(e2e 实测,`has_en_headers=True`、英文标题原样保留、正文全中文):
```
## Objective
- 按用户要求回复"收到"。
## Requirements
- 回复内容必须为"收到"。
...
```
| 分支 | 内容 |
| :---: | :---: |
| main | 主插件,会话切换、skill热加载、用户端mcp配置对调等 |
| ai-mcp | 大模型操作mcp配置对调的插件 |
| wsl-round-mcp | wsl和win之间的skill互推、mcp配置互推等 |
| compaction-cn | 让模型的压缩消息内容使用中文,提供常驻化中文引导,让模型用中文沟通 |
| oc-retry-guard | 流式错误引导,让模型报错时,自主接着干活 |
| model-manager | 大模型管理器,允许配置禁用(`/models`列表不显示)、启用(重新让模型出现在`/models`列表)和恢复出厂(抹除模型的key,让它重新进入/connet的连接通道) |
-345
View File
@@ -1,345 +0,0 @@
// compaction-cn —— OC 对话压缩摘要中文化 server 插件
//
// 背景与动机:
// OC 原生压缩(SessionCompaction)提示词硬编码在 opencode.exe 二进制内,
// opencode.config.compaction 配置 schema 只有 {auto, buffer, keep.tokens},无 prompt 覆盖字段;
// V1 插件 hooks(含官方文档的 experimental.session.compacting)在 OC 2.0.15 V2 运行时
// 【没有任何消费者】(实测:default 对象上的 V1 hook 字段零调用,且 named export 返回
// hooks 对象会被当 cleanup 调用 → TypeError: Ae is not a function 整批崩)。
// 于是压缩摘要默认产出纯英文,后续任务容易被前面的英文摘要带偏。
//
// ── OC 2.0.15 压缩链路(逆向 opencode.exe 实锤,勿重复考古)────────────────
// auto = SessionCompaction.compact(k) reason:"auto" (runStep 里 required:F 判定后调用)
// manual = SessionCompaction.compactManual(T) reason:"manual"
// 两者都进 G(execute) → g(N,msg) → N.prepare(args)
// → SessionModelRequest.prepare → trigger("session","compaction", payload)
// (trigger:顺序执行注册回调、返回【同一个 payload 对象】⇒ 对 system/messages 的原地 push 必然生效)
// → T.system / T.messages → e9({system:T.system, messages:NB(T6(T.messages))}) → 构造 request
// G 在 prepare 返回【之后】才拼压缩模板:
// be = pn.update(we.request, {messages:[...we.request.messages, Zt.user(z$(...))]})
// ⇒ 钩子里追加的消息一定排在模板【前面】。
//
// ── 2026-09-26 根因(A/B 实测,模型 agnes-2.5-flash)──────────────────────
// 注入生效但位置不对。同一模型四组对照(对话全英文、模板一致):
// A. 中文规则放 system 末尾 → cn=0 纯英文(= 插件 v1/v2 的做法)
// B. 中文指令放【模板之后】 → cn=157 中文 ✅
// C. 中文规则放 system 最前面 → cn=0 纯英文
// D. 模板之后追加、用英文措辞 → cn=123 中文 ✅
// ⇒ 模型只认「最后一条消息」里的指令;system 段无论放哪都压不住。
// ⇒ 手动压缩出中文,是因为对话本身就是中文(模型跟随对话语言),与注入无关;
// 自动压缩时上下文几乎全英文,才暴露出「指令位置不对」。
//
// ── 因此干预点分两层(v4)────────────────────────────────────────────────
// ① session.hook("compaction"):向 payload.system 追加完整中文规则(覆盖读 system 的模型)
// ② session.hook("http.request"):【关键】压缩请求出网前解析 JSON body,
// 检测到压缩模板(## Objective + Do not continue the task)后做两件事:
// ②-a 把极短英文语言规则【内联写进模板消息本身】(紧跟 Do not continue the task)
// ②-b 再在 messages 【最末尾】(即模板之后)追加一条独立指令
// 2026-09-26 实测只做 ②-b 时 4 次里有 1 次模型仍输出英文,故 v4 加 ②-a 双保险。
// 非压缩请求 / 非 JSON / 已含标记 → 原样放行,绝不改写。
//
// ── v5(2026-10-12):常驻中文引导 ──────────────────────────────────────
// 需求:模型在【每次工具调用后、思考结束、任何时候】都保持中文回复,
// 不被工具返回的英文结果/日志/网页内容带偏;模型确实不支持中文则可忽略。
// 做法:http.request 钩子扩展到【所有 kind】的 JSON 聊天请求——
// * 压缩请求(指纹命中)→ 走原 v4 双保险逻辑(不变)
// * 其它请求 → 在 messages 【最末尾】追加常驻规则 <reply-language>
// (A/B 实测:该模型只认 messages 最末尾的指令;每轮请求 hook 重新注入,
// 历史不落库,因此每轮都是新注入,天然"任何时候"生效)
// 幂等标记 LANG_MARK 与压缩标记 MARK 分开,互不干扰;
// 日志按 sessionID 60s 节流,避免刷屏。
//
// 参考:session.hook("model.request") 实测 payload keys 只有
// ["sessionID","agent","model","kind","baseURL","headers"],无 system/messages,
// 只能做诊断日志,改不了内容(保留日志便于日后核对)。
//
// 关键约束(不能违反):
// 输出校验 ww = (e) => e.split("\n").some(a => U$.includes(a.trim()))
// U$ = 模板按行过滤出的英文标题(## Objective / ## Requirements / ## Decisions /
// ## Work State / ### Completed / ### Active / ### Blocked / ## Next Move /
// ## Relevant Files / ## Important Context)。
// ⇒ 摘要必须含【整行英文标题】,翻译标题 → 校验失败 → 压缩失败。
// ⇒ 只能要求「内容中文、标题原样英文」,绝不能翻译标题。
// compaction 钩子里若追加消息,content 必须是数组:
// prepare 链路里 T6 会在 Zt.make 之前对 content 调 .map(),给字符串会 TypeError。
//
// 与 ai-mcp 同款的 V2 插件契约(勿破坏):
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
//
// 部署(三份同步 + node --experimental-strip-types 校验):
// 源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts
// WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts
import { appendFileSync, existsSync, mkdirSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
const LOG_DIR = join(homedir(), ".config", "opencode")
const LOG_FILE = join(LOG_DIR, "compaction-cn.log")
// 标记串:幂等判重用(多 location / 多次 reload 可能重复注册钩子)
const MARK = "<compaction-language>"
// system 段注入(对部分模型有效,对只认最后一条消息的模型无效,保留但不作为唯一手段)
const CN_RULE = `${MARK}
以下为本次会话压缩(compaction)摘要的输出语言要求,优先级高于模板中的其他说明:
1. 除第 2 条外,摘要的全部内容必须使用简体中文撰写,包括各小节的条目正文、说明与结论。
即使被压缩的对话原文是英文,摘要正文也必须用简体中文。
2. 所有小节标题必须原样保留英文、不得翻译或改动大小写,包括:
## Objective / ## Requirements / ## Decisions / ## Work State /
### Completed / ### Active / ### Blocked /
## Next Move / ## Relevant Files / ## Important Context
这些标题行是压缩结果的格式校验依据,翻译或改写会导致本次压缩失败。
3. 文件路径、代码、命令、URL、报错原文、标识符以及用户原话引用保持原样,不要翻译。
4. 严格遵守压缩模板中的全部格式规则(单行条目、不超过 15 个相关文件、不要提及压缩过程等)。
5. 若本次为更新模式(已有 checkpoint 要求重写),各节内容同样按上述语言要求用简体中文重写,
不要沿用旧 checkpoint 的英文表述。
6. 禁止输出 <template>、<conversation-checkpoint>、<summary>、<recent-context> 等包裹标签,
禁止任何前言、解释或结尾说明;回复的第一行必须是 "## Objective"。
${MARK}`
// 追加在【压缩模板之后】的短指令 —— 2026-09-26 A/B 实测唯一稳定生效的位置
// 注意:实测单靠这一条仍有小概率被模型无视(4 次里 1 次英文),因此 v4 又把同一要求
// 直接写进压缩模板消息本身(紧跟 "Do not continue the task"),双保险。
const CN_DIRECTIVE = `${MARK}【压缩摘要输出语言强制指令】本条指令优先于上方模板中的其它说明:
- 摘要正文(所有条目)一律用简体中文撰写,即使对话原文是英文。
- 小节标题原样保留英文:## Objective / ## Requirements / ## Decisions / ## Work State /
### Completed / ### Active / ### Blocked / ## Next Move / ## Relevant Files / ## Important Context。
- 回复第一行必须是 "## Objective";禁止输出 <template>、<conversation-checkpoint>、
<summary>、<recent-context> 等包裹标签;禁止任何前言与解释。
- 文件路径、代码、命令、URL、报错原文保持原样。
- 不要把本条指令写进摘要内容。
Final rule: write the summary body in Simplified Chinese, keep the ## headings in English,
first line must be ## Objective, no wrapper tags, do not quote this instruction.
${MARK}`
// 写进压缩模板消息尾部的极短版(紧跟 "Do not continue the task",与模板同一条消息)
const CN_INLINE = `
${MARK}LANGUAGE RULE (overrides everything above): write the whole summary body in Simplified Chinese,
even if the conversation is English. Keep every section heading exactly in English (## Objective etc.).
First line of your reply must be "## Objective". No preamble, no explanation, do not quote this rule.
${MARK}`
// ── v5 常驻中文引导(每个非压缩请求末尾注入,防被英文工具结果带偏)──────
const LANG_MARK = "<reply-language>"
const LANG_DIRECTIVE = `${LANG_MARK}
REPLY LANGUAGE RULE — applies to EVERY reply, at ALL times, including after every tool call and mid-task:
- Always reply to the user in Simplified Chinese (简体中文).
- English appearing in tool outputs, file contents, logs, search results or web pages must NOT switch your reply language.
- Keep code, file paths, commands, URLs, error text and identifiers unchanged (do not translate them).
- If you genuinely cannot output Chinese, ignore this rule.
${LANG_MARK}`
// 压缩请求 body 的指纹(同时命中才动手,避免误改其它请求)
const FP_HEAD = "## Objective"
const FP_TAIL = "Do not continue the task"
function fileLog(msg: string) {
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
// 日志失败绝不影响主流程
}
}
// system 段可能是 {type:"text", text} 或纯字符串,取其文本
function systemText(s: any): string {
if (typeof s === "string") return s
if (s && typeof s === "object") return typeof s.text === "string" ? s.text : ""
return ""
}
// LLM message 的 content 可能是字符串或 [{type,text}] 数组
function msgText(m: any): string {
try {
if (!m) return ""
if (typeof m.text === "string") return m.text
const c = (m as any).content
if (typeof c === "string") return c
if (Array.isArray(c)) return c.map((p: any) => systemText(p)).join("\n")
return ""
} catch {
return ""
}
}
// 往一条 message 的 content 末尾追加文本(字符串 / 数组两种形态都兼容)
function appendToMsg(m: any, tail: string): boolean {
try {
if (!m) return false
if (typeof m.content === "string") {
m.content += tail
return true
}
if (Array.isArray(m.content)) {
for (let i = m.content.length - 1; i >= 0; i--) {
const p = m.content[i]
if (p && typeof p === "object" && typeof p.text === "string") {
p.text += tail
return true
}
}
m.content.push({ type: "text", text: tail })
return true
}
if (typeof m.text === "string") {
m.text += tail
return true
}
return false
} catch {
return false
}
}
// 命名导出:ai-mcp 实验矩阵 ② 的逗号表达式安全形态(返回 hooks 对象会触发 Ae is not a function)
export const CompactionCn: any = async (_ctx: any) => (await ({} as any), undefined as any)
export default {
id: "compaction-cn",
async setup(ctx: any): Promise<void> {
try {
const session = ctx && ctx.session
if (!session || typeof session.hook !== "function") {
fileLog("session.hook 不可用(keys=" + JSON.stringify(Object.keys(session || {})) + "),未注册")
return
}
// ① compaction 钩子:system 段追加中文规则(原地 push ⇒ trigger 返回同一对象,必进 request)
const registration = await session.hook("compaction", (g: any) => {
try {
if (!g) return
const sys = g.system
if (!Array.isArray(sys)) {
fileLog("compaction payload 无 system 数组(type=" + typeof sys + ")keys=" + JSON.stringify(Object.keys(g)))
return
}
if (sys.some((s: any) => systemText(s).indexOf(MARK) >= 0)) return
sys.push({ type: "text", text: CN_RULE })
fileLog(
"已注入中文输出指令(system) sessionID=" +
JSON.stringify(g.sessionID) +
" system段=" +
sys.length +
" messages=" +
(Array.isArray(g.messages) ? g.messages.length : "?"),
)
} catch (e: any) {
fileLog("compaction 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
})
fileLog("compaction 钩子注册完成 registration=" + JSON.stringify(registration && typeof registration === "object" ? Object.keys(registration) : String(registration)))
// ② http.request 钩子:请求出网前的最后关口
// v4:压缩请求 → 模板之后追加语言指令(A/B 实测唯一稳定生效的位置)
// v5:扩展到所有 JSON 聊天请求 → 非压缩请求在 messages 末尾注入常驻中文引导
// 日志节流:同 sessionID 60s 内最多记一条常驻注入日志
const langLogAt = new Map<string, number>()
const hr = await session.hook("http.request", async (f: any) => {
try {
if (!f || !f.request || typeof f.request.clone !== "function") return
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
if (ct && ct.indexOf("json") < 0) return
let raw = ""
try {
raw = await f.request.clone().text()
} catch {
return
}
if (raw.length === 0) return
const kindStr = typeof f.kind === "string" ? f.kind : ""
const isCompaction =
raw.indexOf(FP_HEAD) >= 0 && raw.indexOf(FP_TAIL) >= 0 && (kindStr === "" || kindStr === "compaction")
let obj: any
try {
obj = JSON.parse(raw)
} catch {
return
}
const msgs = obj && obj.messages
if (!Array.isArray(msgs) || msgs.length === 0) return
const lastTxt = msgText(msgs[msgs.length - 1])
// ── v5 常驻中文引导(非压缩请求):末尾不含标记才注入 ──────────
if (!isCompaction) {
if (lastTxt.indexOf(LANG_MARK) >= 0) return
msgs.push({ role: "user", content: [{ type: "text", text: LANG_DIRECTIVE }] })
const headers5 = new Headers(f.request.headers)
headers5.delete("content-length")
f.request = new Request(f.request.url, {
method: f.request.method,
headers: headers5,
body: JSON.stringify(obj),
})
const sid = String(f.sessionID || (obj && obj.sessionID) || "?")
const now = Date.now()
if (!langLogAt.has(sid) || now - (langLogAt.get(sid) as number) > 60000) {
langLogAt.set(sid, now)
fileLog("常驻中文引导已注入 session=" + sid + " kind=" + (kindStr || "?") + " messages=" + msgs.length)
}
return
}
// ── 以下为压缩请求的 v4 双保险逻辑(保持不变)────────────────
if (lastTxt.indexOf(MARK) >= 0) return
fileLog("body 顶层keys=" + JSON.stringify(Object.keys(obj)) + " temperature=" + JSON.stringify(obj.temperature) + " model=" + JSON.stringify(obj.model))
// 诊断:逐条 message 结构(role / 长度 / 开头 60 字)——只记结构不落全文,
// 全文含完整会话内容,落盘既占地方又有隐私问题,定位完成后不再需要。
try {
const brief = msgs
.map((m: any, i: number) => i + ":" + String((m && m.role) || "?") + ":" + msgText(m).length + ":" + msgText(m).slice(0, 60).replace(/\s+/g, " "))
.join(" || ")
fileLog("结构: " + brief)
} catch {}
// ②-a 把语言规则写进压缩模板消息本身(紧跟 FP_TAIL),与模板同一条消息
let inlined = false
for (let i = msgs.length - 1; i >= 0; i--) {
const t = msgText(msgs[i])
if (t.indexOf(FP_HEAD) >= 0 && t.indexOf(FP_TAIL) >= 0) {
if (t.indexOf(MARK) >= 0) break
inlined = appendToMsg(msgs[i], CN_INLINE)
break
}
}
// ②-b 再在 messages 最末尾追加一条独立指令(A/B 实测唯一稳定生效的位置)
msgs.push({ role: "user", content: [{ type: "text", text: CN_DIRECTIVE }] })
const headers = new Headers(f.request.headers)
headers.delete("content-length")
f.request = new Request(f.request.url, { method: f.request.method, headers: headers, body: JSON.stringify(obj) })
fileLog("已在压缩模板后追加中文指令 messages=" + msgs.length + " 模板内联=" + inlined + " 原body=" + raw.length + "字节")
} catch (e: any) {
fileLog("http.request 钩子异常(已吞掉,未改写请求): " + (e && e.message ? e.message : String(e)))
}
})
fileLog("http.request 钩子注册完成 registration=" + JSON.stringify(hr && typeof hr === "object" ? Object.keys(hr) : String(hr)))
// ③ model.request 诊断:实测 payload 只有 sessionID/agent/model/kind/baseURL/headers
const mr = await session.hook("model.request", (p: any) => {
try {
if (!p || p.kind !== "compaction") return
fileLog(
"model.request(compaction) keys=" +
JSON.stringify(Object.keys(p)) +
" system=" +
(Array.isArray(p.system) ? p.system.length : typeof p.system) +
" messages=" +
(Array.isArray(p.messages) ? p.messages.length : typeof p.messages),
)
} catch (e: any) {
fileLog("model.request 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
})
fileLog("model.request 钩子注册完成 registration=" + JSON.stringify(mr && typeof mr === "object" ? Object.keys(mr) : String(mr)))
} catch (e: any) {
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
}
// setup 必须返回 void:返回 hooks 对象会触发 Ae is not a function 整批崩溃
return
},
}
+422
View File
@@ -0,0 +1,422 @@
{
"name": "oc-session-all",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oc-session-all",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "latest"
},
"devDependencies": {
"typescript": "^5.0.0"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.18.31",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.31.tgz",
"integrity": "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA==",
"license": "MIT",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "1.18.31",
"effect": "4.0.0-beta.83",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.18.31",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz",
"integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.83",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz",
"integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.8.0",
"find-my-way-ts": "^0.1.6",
"ini": "^7.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^2.0.1",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^14.0.0",
"yaml": "^2.9.0"
}
},
"node_modules/fast-check": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
"integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz",
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
"license": "ISC",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz",
"integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uuid": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "oc-session-all",
"version": "2.1.0",
"description": "OpenCode v2 TUI plugin: browse and switch to any session across all projects",
"main": "tui.js",
"type": "module",
"exports": {
".": "./tui.js",
"./tui": "./tui.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"opencode",
"tui",
"plugin",
"session",
"picker"
],
"author": "",
"license": "MIT",
"engines": {
"opencode": ">=2.0.0"
}
}
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""
_wsl_one_shot_fix.py — 一次性把 Win 侧模型数据 + 插件推给 WSL(立即生效,不等收尸)。
★ 用户终端跑: python <本脚本> (wsl.exe 实际调用在用户终端可执行; 本环境沙箱拦 wsl.exe)
对齐 2026-10-20 重定位后的 oc-model-sync.py:
1. 读 Win opencode.db 的 models-dev:catalog + credential(xiaomi 原厂直连密钥) + model.json
2. 推 catalog + credential + model.json + 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 给 WSL
3. WSL import: 备份后 catalog 整份覆盖(删旧加新), credential 并集灌入(保留 WSL 独有),
model.json 覆盖, 插件写回 → /models 主列表 + /manage + xiaomi 原厂模型全部对齐 Win
复用 oc-model-sync.py 的导出/导入逻辑, 不重复造轮子。
"""
import importlib.util
import json
import os
import sys
HOME = os.path.expanduser("~")
MSYN = os.path.join(HOME, ".config", "opencode", "oc-model-sync.py")
def main():
spec = importlib.util.spec_from_file_location("ms", MSYN)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
if not os.path.exists(m.WIN_DB):
print("Win opencode.db 缺失: %s" % m.WIN_DB)
return 1
catalog, creds = m.read_win_data()
print("导出: catalog=%dB credentials=%d" % ((len(catalog) if catalog else 0), len(creds)))
if not catalog:
print("Win db 无 catalog, 无需推。")
return 0
# 1) 推 catalog
rc = m.wsl_push(catalog.encode("utf-8"), m.REMOTE_CATALOG, timeout=200)
print("catalog push rc=%d" % rc)
if rc != 0:
return 1
# 2) 推 credentials
rc = m.wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"), m.REMOTE_CREDS, timeout=120)
print("creds push rc=%d" % rc)
if rc != 0:
return 1
# 3) 推 model.json
if os.path.exists(m.WIN_STATE):
rc = m.wsl_push(open(m.WIN_STATE, "rb").read(), m.REMOTE_STATE, timeout=120)
print("state push rc=%d" % rc)
if rc != 0:
return 1
# 4) 推插件 blob
rc = m.wsl_push(json.dumps(m.build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
m.REMOTE_PLUGINS, timeout=120)
print("plugins push rc=%d" % rc)
if rc != 0:
return 1
# 5) WSL import(覆盖 catalog + 并集 credential + 写 model.json + 写插件, 带备份回滚)
rc, _ = m.wsl_py(m.IMPORT_SRC, timeout=200)
print("import rc=%d" % rc)
res = m.wsl_cat(m.REMOTE_RESULT)
print("result:", res[:500])
print("OK. 重启 WSL 侧 OC(收尸 terminate) 后 /models 与 /mcpon /mcpoff /manage 即对齐 Win。")
return 0
if __name__ == "__main__":
sys.exit(main())
+418
View File
@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
"""
oc-model-sync.py - 模型数据同步: 本机(Win) -> WSL。
收尸链路 step 6a 调用(在 session-sync 之前)。设计(2026-10-02; 2026-10-12; 2026-10-20 二次重定位):
★ 2026-10-20 二次修正(用户实锤「大错特错」): 上一版把数据推到 WSL 的 *config provider 文件*
(.oc-model-sync.json / opencode.json), 但 OC 真正读的是三处不同数据源:
① /models 主列表 <- DB catalog (models-dev:catalog)
② /manage 面板 <- config provider (opencode.json) ∩ catalog ∩ credential
③ xiaomi 原厂模型 <- credential 直连(active 的 integration), OC 动态拉取
推错树 = /models 不变、/manage 只显示 1/localhost 两个 config 源、xiaomi 读不出。
且「只增不删」合并让你 cc-switch 已移除的模型在 WSL 残留。
本版(彻底对齐 Win 现状):
- 数据源 = Win 的 opencode.db(唯一真源):
catalog: kv 表 key='models-dev:catalog'(OC 动态拉取的全部 provider/models)
credential: credential 表全量(含 xiaomi 原厂密钥, 决定 /manage 能否命中 xiaomi)
model.json: ~/.local/state/opencode/model.json (recent/favorite 使用历史)
- 传输: ① catalog(6.6MB) 推远端 ② credential(小) 推远端 ③ model.json 推远端
④ 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 推远端
- WSL import(★ 覆盖式, 删旧加新):
* 备份 WSL db -> /root/.local/share/opencode/opencode.db.bak-modelsync
* 覆盖 WSL 的 models-dev:catalog = Win catalog(WSL /models 与 Win 完全一致, 已删的厂商消失)
* 覆盖 WSL credential = Win credential(保留 WSL 独有的 active credential? 否: 镜像 Win)
* 写 WSL model.json = Win model.json(recent/favorite 对齐)
* 写插件到 WSL 生效路径(修 /mcpon /mcpoff /manage 失效)
* 任何一步失败 -> 回滚(用备份恢复 WSL db), 绝不写坏 WSL
- 幂等: 指纹 = Win catalog sha + credential sha + model.json sha + 插件 sha, 无变化秒退
- 预算 150s; 异常吞掉只写日志([model-sync] 前缀), 绝不阻塞退出链路
"""
import base64
import hashlib
import io
import json
import os
import sqlite3
import subprocess
import sys
import time
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
TAG = "[model-sync]"
HOME = os.path.expanduser("~")
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
WIN_STATE = os.path.join(HOME, ".local", "state", "opencode", "model.json")
STATE = os.path.join(HOME, ".config", "opencode", ".oc_model_state.json")
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
DISTRO = "Ubuntu-24.04"
BUDGET = 150
REMOTE_CATALOG = "/root/.oc_catalog.json"
REMOTE_CREDS = "/root/.oc_creds.json"
REMOTE_STATE = "/root/.oc_state.json"
REMOTE_PLUGINS = "/root/.oc_plugins.json"
REMOTE_RESULT = "/root/.oc_model_result.json"
# Win 生效份插件 -> WSL 生效路径(修「插件对不上」: WSL 落后导致 /mcpon /mcpoff /manage 失效)
PLUGINS = [
(os.path.join(HOME, ".config", "opencode", "plugins", "oc-session-all", "tui.js"),
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
(os.path.join(HOME, ".config", "opencode", "plugins", "model-manager.ts"),
"/root/.config/opencode/plugins/model-manager.ts"),
(os.path.join(HOME, ".config", "opencode", "mcpcfg.txt"),
"/root/.config/opencode/mcpcfg.txt"),
# ── 2026-10-20 治本:provider + MCP 配置同步(替代 model-manager 压制清单)──
# 事实定论(用户纠正):
# ① cc-switch 只往 opencode.json 写(增删模型时写 provider 字段),完全不碰 opencode.json.2
# ② opencode.json.2 是用户自己从 opencode.json 复制来的副本,给 ai-mcp 对调机制用,非 cc-switch 管
# ③ MCP server 手动加在 opencode.json,不能用 cc-switch 添加
# 同步策略:
# 推 opencode.json -> /root/.config/opencode/opencode.json(provider + MCP 完整同步)
# cc-switch 删/加模型改 opencode.json.provider -> 收尸同步 -> WSL 一致,无需手动压制
# 注意:opencode.json 不能进 build_plugin_blob 的「Win 路径转 WSL 路径」逻辑(provider 是相对 key,无绝对路径)
(os.path.join(HOME, ".config", "opencode", "opencode.json"),
"/root/.config/opencode/opencode.json"),
(os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"),
"/root/.config/opencode/model-manager.cfg.json"),
# ── 2026-10-20 补:model-manager 在 WSL 失效的根因 ──
# ① model-manager-db-exec.py(Python DB 写执行器)是 model-manager.ts 的硬依赖,
# 它 fork 这个子进程去写 SQLite catalog。WSL 没有 → execDbWrite spawn-fail
# → 停用/恢复/删除厂商全不生效(WSL 侧「无法停用/恢复出厂」的直接根因)。
(os.path.join(HOME, ".config", "opencode", "model-manager-db-exec.py"),
"/root/.config/opencode/model-manager-db-exec.py"),
# ② tui.json(注册 TUI 插件 oc-session-all.js)+ tui-plugins 本体。
# WSL 侧缺 tui.json → TUI 插件没注册 → /mcpon /mcpoff 斜杠命令不存在
# → 截图 3「/mcp-off No matching commands」。
(os.path.join(HOME, ".config", "opencode", "tui.json"),
"/root/.config/opencode/tui.json"),
(os.path.join(HOME, ".config", "opencode", "tui-plugins", "oc-session-all.js"),
"/root/.config/opencode/tui-plugins/oc-session-all.js"),
]
def log(m):
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
except Exception:
pass
def fingerprint():
"""Win 侧数据源 + 插件 的内容指纹(全量 sha, 任何变都触发重推)。"""
h = hashlib.sha256()
try:
h.update(hashlib.sha256(open(WIN_DB, "rb").read()).digest())
except Exception:
h.update(b"no-win-db")
try:
h.update(hashlib.sha256(open(WIN_STATE, "rb").read()).digest())
except Exception:
h.update(b"no-win-state")
for src, _ in PLUGINS:
try:
h.update(hashlib.sha256(open(src, "rb").read()).digest())
except Exception:
h.update(b"no-plugin")
return h.hexdigest()
def read_win_data():
"""只读 Win db + opencode.json.2:返回 (catalog_value, credentials_json, n_creds)。"""
import sqlite3 as _s
con = _s.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
try:
cur = con.cursor()
row = cur.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
catalog = row[0] if row else None
cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
cre_rows = cur.execute("SELECT * FROM credential").fetchall()
creds = [dict(zip(cols, r)) for r in cre_rows]
finally:
con.close()
return catalog, creds
def build_plugin_blob():
"""Win 生效份插件 + opencode.json -> {dst_path: base64}。
mcpcfg.txt / tui.json 特殊处理:把 Win 绝对路径(反斜杠或 file:///C:/)转 WSL 绝对路径。
model-manager 压制清单:Win 原样推(Win 空则 WSL 也空)。
注意:opencode.json 的 provider 是相对 key(如 '1'),无绝对路径,直接 base64 推。
"""
blob = {}
# Win 侧各路径前缀 -> WSL 前缀(用 chr(92) 拼反斜杠,绕开源码转义踩坑)
bk = chr(92)
win_prefix_bk = "C:" + bk + "Users" + bk + "ypshidifu" + bk + ".config" + bk + "opencode" + bk
win_prefix_sl = "C:/Users/ypshidifu/.config/opencode/"
win_prefix_file = "file:///C:/Users/ypshidifu/.config/opencode/"
wsl_prefix = "/root/.config/opencode/"
wsl_prefix_file = "file:///root/.config/opencode/"
for src, dst in PLUGINS:
if not os.path.exists(src):
continue
try:
data = open(src, "rb").read()
# mcpcfg.txt / tui.json:Win 路径前缀 -> WSL 路径前缀
# - 普通反斜杠路径(mcpcfg.txt)
# - file:// 绝对路径(tui.json 里 TUI 插件注册)
if os.path.basename(src) in ("mcpcfg.txt", "tui.json"):
text = data.decode("utf-8")
text = text.replace(win_prefix_file, wsl_prefix_file) # 先替换 file:// 长前缀
text = text.replace(win_prefix_bk, wsl_prefix)
text = text.replace(win_prefix_sl, wsl_prefix)
data = text.encode("utf-8")
blob[dst] = base64.b64encode(data).decode("ascii")
except Exception:
pass
# model-manager 压制清单(Win 原样推,Win 空则 WSL 也空)
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
"model-manager.stoplist.bak.json"]:
fsrc = os.path.join(HOME, ".config", "opencode", fname)
fdst = "/root/.config/opencode/" + fname
if os.path.exists(fsrc):
try:
blob[fdst] = base64.b64encode(open(fsrc, "rb").read()).decode("ascii")
except Exception:
pass
return blob
def wsl_push(data_bytes, remote_path, timeout=150):
cmd = 'cat > "%s"' % remote_path
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
input=data_bytes,
capture_output=True,
timeout=timeout,
)
return r.returncode
def wsl_py(src, timeout=150):
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
input=src.encode("utf-8"),
capture_output=True,
timeout=timeout,
)
return r.returncode, r.stdout
def wsl_cat(remote_path, timeout=60):
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
capture_output=True,
timeout=timeout,
)
raw = r.stdout or b""
if raw[:1] == b"{":
return raw.decode("utf-8", errors="replace")
try:
t = raw.decode("utf-16-le")
if "{" in t:
return t[t.index("{"):]
except Exception:
pass
return raw.decode("utf-8", errors="replace")
# ── WSL 侧 import 脚本(★ 覆盖式 + 备份回滚)────────────────────────────────
IMPORT_SRC = r'''
import json, os, sqlite3, shutil, time, base64
res = {"ok": False, "steps": []}
try:
WSL_DB = "/root/.local/share/opencode/opencode.db"
WSL_STATE = "/root/.local/state/opencode/model.json"
BACKUP = WSL_DB + ".bak-modelsync"
catalog_raw = open("/root/.oc_catalog.json", encoding="utf-8").read()
creds = json.load(open("/root/.oc_creds.json", encoding="utf-8"))
state = open("/root/.oc_state.json", encoding="utf-8").read()
plugin_blob = json.load(open("/root/.oc_plugins.json", encoding="utf-8"))
# ① 备份 WSL db(覆盖写前必做)
if os.path.exists(WSL_DB):
shutil.copy2(WSL_DB, BACKUP)
res["steps"].append("backup:" + BACKUP)
else:
res["steps"].append("no-wsl-db")
# ② 覆盖 catalog + credential(删旧加新, 镜像 Win)
con = sqlite3.connect(WSL_DB)
cur = con.cursor()
# 表缺失才建(按 OC 真实 schema: credential.id 是 TEXT, 不是 INTEGER)
cur.execute("""CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY, value TEXT, time_updated INTEGER)""")
cur.execute("""CREATE TABLE IF NOT EXISTS credential (
id TEXT, integration_id TEXT, label TEXT, value TEXT,
connector_id TEXT, method_id TEXT, active INTEGER,
time_created INTEGER, time_updated INTEGER)""")
if catalog_raw:
cur.execute("DELETE FROM kv WHERE key='models-dev:catalog'")
# ★ 按 WSL kv 表实际列动态插入(补 time_created 等, 避免 NOT NULL 约束失败)
kv_cols = [r[1] for r in cur.execute("PRAGMA table_info(kv)")]
now = int(time.time())
row = {"key": "models-dev:catalog", "value": catalog_raw,
"time_created": now, "time_updated": now}
cols = [k for k in row.keys() if k in kv_cols]
# WSL kv 有但上面没给的列 -> 补 NULL/0
for k in kv_cols:
if k not in row:
row[k] = now if k.startswith("time_") else None
cols.append(k)
cur.execute(
"INSERT INTO kv (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
[row[k] for k in cols])
res["steps"].append("catalog-cleared+overwritten(cols=%s)" % cols)
else:
res["steps"].append("catalog-missing-skip")
# credential: 并集语义 —— Win 的 credential 整份灌入, 但 WSL 独有的 integration_id
# 保留不动(不破坏 WSL 本地连的别家厂商); Win 同名的按 Win 覆盖(值/active 以 Win 为准)。
cred_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
win_by_integ = {}
for c in creds:
win_by_integ[c.get("integration_id")] = c
existing = cur.execute("SELECT * FROM credential").fetchall()
ex_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
wsl_only_integs = []
for r in existing:
ed = dict(zip(ex_cols, r))
integ = ed.get("integration_id")
if integ in win_by_integ:
# WSL 同名的删掉, 下面用 Win 值重灌
cur.execute("DELETE FROM credential WHERE id=?", (ed.get("id"),))
else:
wsl_only_integs.append(integ) # WSL 独有, 保留
for integ, c in win_by_integ.items():
row = dict(c)
cols = [k for k in row.keys() if k in cred_cols]
# WSL credential 有但 Win 没给的列 -> 按类型补默认值, 避免 NOT NULL 约束失败
for k in cred_cols:
if k not in row:
if k.startswith("time_"):
row[k] = int(time.time())
elif k == "active":
row[k] = 0
elif k == "id":
# 无 id 或 id 缺失时生成稳定 id(避免 PRIMARY KEY 冲突与 NOT NULL)
row[k] = "win_" + (integ or "unknown").replace("/", "_")
else:
row[k] = None
cols.append(k)
# label 是 NOT NULL:Win 可能给空串/None,确保非 None
if row.get("label") is None:
row["label"] = row.get("id") or row.get("integration_id") or "win"
if not cols:
continue
# 防重复: 若 WSL 里已存在同名(可能上面没删净), 先清
cur.execute("DELETE FROM credential WHERE integration_id=?", (integ,))
cur.execute(
"INSERT INTO credential (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
[row.get(k) for k in cols])
con.commit()
con.close()
res["steps"].append("credential-merged(win=%d, wsl_only_kept=%s)" %
(len(win_by_integ), wsl_only_integs))
# ③ 写 model.json(recent/favorite 对齐)
try:
os.makedirs(os.path.dirname(WSL_STATE), exist_ok=True)
open(WSL_STATE, "w", encoding="utf-8").write(state)
res["steps"].append("model.json-overwritten")
except Exception as e:
res["steps"].append("model.json-err:" + str(e))
# ④ 写插件(修 /mcpon /mcpoff /manage 失效)
for dst, b64 in plugin_blob.items():
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, "wb").write(base64.b64decode(b64))
except Exception as e:
res["steps"].append("plugin-err:" + dst + ":" + str(e))
res["steps"].append("plugins-written(%d)" % len(plugin_blob))
res["ok"] = True
except Exception as e:
# 任何一步失败 -> 回滚 WSL db
res["err"] = str(e)
try:
if os.path.exists(BACKUP) and os.path.exists(WSL_DB):
shutil.copy2(BACKUP, WSL_DB)
res["rolled_back"] = True
res["steps"].append("ROLLBACK:" + BACKUP)
except Exception as e2:
res["rollback_err"] = str(e2)
open("/root/.oc_model_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
def main():
t0 = time.time()
fp = fingerprint()
try:
state = json.load(open(STATE, encoding="utf-8")).get("fp")
except Exception:
state = None
if state == fp:
log("unchanged, skip (fp=%s)" % fp[:12])
return
if not os.path.exists(WIN_DB):
log("win db missing: " + WIN_DB)
return
catalog, creds = read_win_data()
log("export: catalog=%dB credentials=%d (fp=%s)" %
((len(catalog) if catalog else 0), len(creds), fp[:12]))
if not catalog:
log("no catalog in win db, nothing to push")
return
# 推 catalog(raw 字符串)
try:
rc = wsl_push(catalog.encode("utf-8"), REMOTE_CATALOG, timeout=200)
if rc != 0:
log("catalog push rc=%d" % rc)
return
# 推 credentials
rc = wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"),
REMOTE_CREDS, timeout=120)
if rc != 0:
log("creds push rc=%d" % rc)
return
# 推 model.json
rc = 0
if os.path.exists(WIN_STATE):
rc = wsl_push(open(WIN_STATE, "rb").read(), REMOTE_STATE, timeout=120)
if rc != 0:
log("state push rc=%d" % rc)
return
# 推插件 blob
rc = wsl_push(json.dumps(build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
REMOTE_PLUGINS, timeout=120)
if rc != 0:
log("plugins push rc=%d" % rc)
return
# 跑 import(覆盖 WSL db + model.json + 插件, 带备份回滚)
rc, _ = wsl_py(IMPORT_SRC, timeout=200)
if rc != 0:
log("import rc=%d" % rc)
return
res = json.loads(wsl_cat(REMOTE_RESULT))
log("import result: " + json.dumps(res, ensure_ascii=False)[:400])
json.dump({"fp": fp}, open(STATE, "w", encoding="utf-8"))
log("state updated in %ds" % int(time.time() - t0))
except Exception as e:
log("exception: " + str(e))
if __name__ == "__main__":
try:
main()
except Exception as e:
log("FATAL: " + str(e))
sys.exit(0)
+445
View File
@@ -0,0 +1,445 @@
# -*- coding: utf-8 -*-
"""
oc-session-sync.py - 对话数据同步: 本机(Win) -> WSL。
收尸链路 step 7 调用。设计(2026-10-02 首版; 2026-10-12 重建, 适配 2.0.27 schema):
- 幂等: .oc_sync_state.json 存 {session_id: time_updated} 快照, 无变化秒退。
- 并集语义: 绝不删 WSL 数据, 只补缺:
* WSL 缺的 session -> 整行插 + 其全部 message/part
* 两边都有但本机 time_updated 更新 -> 只补 WSL 缺的 message/part;
time_updated 只增不减 (max 更新)。
- 传输: export gzip -> `wsl -- bash -c "cat > file"` stdin 管道(绕 UNC OSError 22);
WSL 侧脚本一律经 stdin 喂 python3 -, 脚本只读本地文件(避免 stdin 冲突)。
- 跨版本 schema: pragma table_info 取目标表列交集, INSERT OR IGNORE。
- 预算 150s; 异常吞掉只写日志([sync] 前缀), 绝不抛出阻塞退出链路。
"""
import gzip
import io
import json
import os
import sqlite3
import subprocess
import sys
import time
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
TAG = "[sync]"
HOME = os.path.expanduser("~")
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
STATE = os.path.join(HOME, ".config", "opencode", ".oc_sync_state.json")
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
REMOTE_IMPORT = "/root/.oc_sync_import.json.gz"
REMOTE_PROBE = "/root/.oc_sync_probe.json"
REMOTE_RESULT = "/root/.oc_sync_result.json"
DISTRO = "Ubuntu-24.04"
BUDGET = 150
# 同步的表(WSL 侧缺表/缺列自动跳过)
TABLES = ["project", "session", "session_v2", "session_message", "part"]
# ★ project.worktree 路径转换(Win 绝对路径 -> WSL 绝对路径)
# Win 侧 session 的 project.worktree = C:/Users/ypshidifu/... (OC 启动 cwd)
# 推给 WSL 后原样是 Win 路径 → WSL 侧 session 面板提示
# 「Session location unavailable / C:/Users/ypshidifu / Choose another directory」。
# 必须把 C:/Users/ypshidifu 前缀转成 WSL 的 /root。
_WIN_HOME_PREFIX_SL = "C:/Users/ypshidifu"
_WSL_HOME_PREFIX = "/root"
def log(m):
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
except Exception:
pass
def wsl_py(src, timeout=120):
"""经 stdin 喂脚本给 wsl python3 - (脚本内禁止再读 stdin)。返回 (rc, out_bytes)。"""
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
input=src.encode("utf-8"),
capture_output=True,
timeout=timeout,
)
return r.returncode, r.stdout
def wsl_push(data, remote_path, timeout=120):
cmd = 'cat > "%s"' % remote_path
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
input=data,
capture_output=True,
timeout=timeout,
)
return r.returncode
def wsl_cat(remote_path, timeout=60):
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
capture_output=True,
timeout=timeout,
)
raw = r.stdout or b""
if raw[:1] == b"{":
return raw.decode("utf-8", errors="replace")
try:
t = raw.decode("utf-16-le")
if "{" in t:
return t[t.index("{"):]
except Exception:
pass
return raw.decode("utf-8", errors="replace")
PROBE_SRC = r'''
import json, os, sqlite3, glob
out = {"ok": False}
try:
cands = ["/root/.local/share/opencode/opencode.db"]
cands += glob.glob("/root/.local/share/**/opencode.db", recursive=True)
db = next((c for c in cands if os.path.exists(c)), None)
if not db:
out["err"] = "wsl db not found"
else:
con = sqlite3.connect(db)
def tu(t):
try:
return {r[0]: (r[1] or 0) for r in con.execute("select id, time_updated from " + t)}
except Exception:
return {}
out["db"] = db
# 2026-10-20:双表驱动(新版 OC 活跃会话写 session_v2,旧版写 session)
out["sessions"] = tu("session")
out["sessions_v2"] = tu("session_v2")
out["msgs"] = tu("session_message")
out["ok"] = True
con.close()
except Exception as e:
out["err"] = str(e)
open("/root/.oc_sync_probe.json", "w", encoding="utf-8").write(json.dumps(out))
'''
IMPORT_SRC = r'''
import gzip, json, os, sqlite3
res = {"ok": False}
try:
raw = gzip.decompress(open("/root/.oc_sync_import.json.gz", "rb").read())
payload = json.loads(raw.decode("utf-8"))
con = sqlite3.connect("/root/.local/share/opencode/opencode.db")
cur = con.cursor()
stats = {}
for t, rows in payload.get("tables", {}).items():
try:
tgt_cols = [r[1] for r in cur.execute("pragma table_info(%s)" % t)]
except Exception:
tgt_cols = []
if not tgt_cols:
stats[t] = "missing-table"
continue
ins = 0
upd = 0
for row in rows:
cols = [c for c in tgt_cols if c in row]
if not cols or "id" not in cols:
continue
try:
cur.execute(
"INSERT OR IGNORE INTO %s (%s) VALUES (%s)"
% (t, ",".join(cols), ",".join("?" * len(cols))),
[row[c] for c in cols],
)
if cur.rowcount > 0:
ins += cur.rowcount
elif "time_updated" in tgt_cols and "time_updated" in row and row["time_updated"]:
cur.execute(
"UPDATE %s SET time_updated=? WHERE id=? AND (time_updated IS NULL OR time_updated<?)"
% t,
[row["time_updated"], row["id"], row["time_updated"]],
)
upd += cur.rowcount
except Exception:
pass
stats[t] = {"ins": ins, "upd": upd}
con.commit()
con.close()
res = {"ok": True, "stats": stats}
except Exception as e:
res = {"err": str(e)}
open("/root/.oc_sync_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
# ★ 存量 Win 路径洗白(2026-10-20 二次实锤:OC 2.x 的 "Session location" 读的是
# session/session_v2 表自身的 directory 列,不只是 project.worktree!
# 目标会话 ses_eb074c0daffe 的 session_v2.directory = "C:/Users/ypshidifu" 即截图来源。
# 且 IMPORT 用 INSERT OR IGNORE —— WSL 已存在的行永远不会被 UPDATE,
# 推送侧转换只对「新插入」的行生效。此脚本每次同步无条件跑(幂等 UPDATE),
# 把三张表里所有 Win home 前缀的路径洗成 /root。
FIX_WORKTREE_SRC = r'''
import sqlite3
BK = chr(92)
PRE_SL = "C:/Users/ypshidifu"
PRE_BK = "C:" + BK + "Users" + BK + "ypshidifu"
res = {"fixed": {}, "err": None}
def conv(p):
if not p or not isinstance(p, str):
return None
if p.startswith(PRE_SL):
return "/root" + p[len(PRE_SL):]
if p.startswith(PRE_BK):
return "/root" + p[len(PRE_BK):]
return None
try:
con = sqlite3.connect("/root/.local/share/opencode/opencode.db")
cur = con.cursor()
# project.worktree
n = 0
for pid, wt in cur.execute("select id, worktree from project").fetchall():
nw = conv(wt)
if nw:
cur.execute("update project set worktree=? where id=?", (nw, pid))
n += 1
res["fixed"]["project.worktree"] = n
# session.directory(列存在才跑)
def fix_dir(table):
cols = [r[1] for r in cur.execute("PRAGMA table_info(%s)" % table)]
if "directory" not in cols:
return -1
n = 0
for sid, d in cur.execute("select id, directory from %s" % table).fetchall():
nw = conv(d)
if nw:
cur.execute("update %s set directory=? where id=?" % table, (nw, sid))
n += 1
return n
res["fixed"]["session.directory"] = fix_dir("session")
res["fixed"]["session_v2.directory"] = fix_dir("session_v2")
con.commit()
con.close()
except Exception as e:
res["err"] = str(e)
open("/root/.oc_fix_wt_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
def main():
t0 = time.time()
if not os.path.exists(WIN_DB):
log("win db missing: " + WIN_DB)
return
# 2026-10-20:双表驱动。新版 OC(2.0.27) 活跃会话写 session_v2,旧表 session 是历史遗留。
# 只盯 session 旧表会导致「Win 新建会话全在 v2 → 旧表无变化 → 永远 skip → WSL 读不到」。
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
try:
win_sessions = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session")}
except Exception as e:
log("win session read ERR: " + str(e))
return
finally:
win.close()
try:
rc, _ = wsl_py(PROBE_SRC, timeout=90)
if rc != 0:
log("wsl probe rc=" + str(rc) + " (vm cold-boot or python missing?)")
return
probe = json.loads(wsl_cat(REMOTE_PROBE))
if not probe.get("ok"):
log("wsl probe ERR: " + str(probe.get("err")))
return
except Exception as e:
log("wsl probe exception: " + str(e))
return
wsl_sessions = probe.get("sessions") or {}
wsl_sessions_v2 = probe.get("sessions_v2") or {}
wsl_msgs = probe.get("msgs") or {}
# ★ 存量 worktree 洗白:每次无条件跑(幂等,修 INSERT OR IGNORE 洗不掉存量行的洞)
try:
rc_fw, _ = wsl_py(FIX_WORKTREE_SRC, timeout=60)
if rc_fw == 0:
fw = json.loads(wsl_cat("/root/.oc_fix_wt_result.json"))
if fw.get("fixed"):
log("worktree fixed: %d rows -> /root" % fw["fixed"])
if fw.get("err"):
log("worktree fix err: " + str(fw["err"]))
except Exception as e:
log("worktree fix exception: " + str(e))
# WSL 侧合并双表(id -> 最新 time_updated)
wsl_all = dict(wsl_sessions)
for k, v in (wsl_sessions_v2 or {}).items():
wsl_all[k] = max(wsl_all.get(k, 0), v)
# 读 Win session_v2(新版主表;缺表当空)
try:
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
win_sessions_v2 = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session_v2")}
except Exception:
win_sessions_v2 = {}
finally:
win.close()
# Win 侧合并双表
win_all = dict(win_sessions)
for k, v in win_sessions_v2.items():
win_all[k] = max(win_all.get(k, 0), v)
# 幂等快照(按合并后的双表快照判断,任一表变都触发重推)
snapshot = {
"session": win_sessions,
"session_v2": win_sessions_v2,
}
try:
state = json.load(open(STATE, encoding="utf-8"))
except Exception:
state = {}
# 兼容旧版快照(旧版直接存 {id: time} 平铺)
if not isinstance(state, dict) or "session" not in state:
state = {"session": state if isinstance(state, dict) else {}, "session_v2": {}}
if state == snapshot:
log("unchanged, skip (session=%d v2=%d)" % (len(win_sessions), len(win_sessions_v2)))
return
# diff:合并双表后的缺失/过期
need_sessions = [s for s in win_all if s not in wsl_all]
stale = [s for s in win_all if s in wsl_all and win_all[s] > wsl_all[s]]
targets = set(need_sessions) | set(stale)
if not targets:
json.dump(snapshot, open(STATE, "w", encoding="utf-8"))
log("no missing/stale sessions; state refreshed (session=%d v2=%d)" %
(len(win_sessions), len(win_sessions_v2)))
return
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
win.row_factory = sqlite3.Row
try:
# collect message ids per target session (need full set for new, missing-only handled at import by OR IGNORE)
msg_rows = []
for r in win.execute(
"select * from session_message where session_id in (%s)"
% ",".join("?" * len(targets)),
list(targets),
):
msg_rows.append(dict(r))
msg_ids = [m["id"] for m in msg_rows if m["id"] not in wsl_msgs]
missing_msg = [m for m in msg_rows if m["id"] not in wsl_msgs]
tables = {}
# project rows referenced by target sessions(双表并集查 project_id)
pids = set()
q1 = "select distinct project_id from session where id in (%s)" % ",".join("?" * len(targets))
for r in win.execute(q1, list(targets)):
if r[0]:
pids.add(r[0])
try:
for r in win.execute(
"select distinct project_id from session_v2 where id in (%s)" % ",".join("?" * len(targets)),
list(targets),
):
if r[0]:
pids.add(r[0])
except Exception:
pass
if pids:
tables["project"] = [
dict(r)
for r in win.execute(
"select * from project where id in (%s)" % ",".join("?" * len(pids)),
list(pids),
)
]
tables["session"] = [
dict(r)
for r in win.execute(
"select * from session where id in (%s)" % ",".join("?" * len(targets)),
list(targets),
)
]
# ★ Win 路径转换(C:/Users/ypshidifu / C:\Users\ypshidifu -> /root)
# 修 WSL 侧「Session location unavailable / C:/Users/ypshidifu」
# 2026-10-20 二次实锤:OC 2.x 的 location 显示读 session/session_v2 自己的
# directory 列,project.worktree 也要转,三处一起。
def _conv_win_path(p):
if not isinstance(p, str):
return p
if p.startswith(_WIN_HOME_PREFIX_SL):
return _WSL_HOME_PREFIX + p[len(_WIN_HOME_PREFIX_SL):]
if p.startswith("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"):
return _WSL_HOME_PREFIX + p[len("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"):]
return p
if tables.get("project"):
for _pr in tables["project"]:
_pr["worktree"] = _conv_win_path(_pr.get("worktree"))
for _t in ("session", "session_v2"):
for _r in tables.get(_t) or []:
if "directory" in _r:
_r["directory"] = _conv_win_path(_r.get("directory"))
try:
tables["session_v2"] = [
dict(r)
for r in win.execute(
"select * from session_v2 where id in (%s)" % ",".join("?" * len(targets)),
list(targets),
)
]
except Exception:
tables["session_v2"] = []
tables["session_message"] = missing_msg
# parts of missing messages
if msg_ids:
parts = []
chunk = 400
for i in range(0, len(msg_ids), chunk):
sub = msg_ids[i : i + chunk]
for r in win.execute(
"select * from part where message_id in (%s)" % ",".join("?" * len(sub)),
sub,
):
parts.append(dict(r))
tables["part"] = parts
else:
tables["part"] = []
except Exception as e:
win.close()
log("win export ERR: " + str(e))
return
win.close()
payload = json.dumps({"tables": tables}, ensure_ascii=False).encode("utf-8")
blob = gzip.compress(payload, 1)
nmsg = len(tables.get("session_message") or [])
npart = len(tables.get("part") or [])
log(
"diff: new=%d stale=%d msgs=%d parts=%d payload=%dKB"
% (len(need_sessions), len(stale), nmsg, npart, len(blob) // 1024)
)
try:
rc = wsl_push(blob, REMOTE_IMPORT, timeout=120)
if rc != 0:
log("push rc=" + str(rc))
return
rc, _ = wsl_py(IMPORT_SRC, timeout=120)
if rc != 0:
log("import rc=" + str(rc))
return
res = json.loads(wsl_cat(REMOTE_RESULT))
log("import result: " + json.dumps(res, ensure_ascii=False)[:300])
json.dump(snapshot, open(STATE, "w", encoding="utf-8"))
log("state updated (session=%d v2=%d) in %ds" %
(len(win_sessions), len(win_sessions_v2), int(time.time() - t0)))
except Exception as e:
log("sync exception: " + str(e))
if __name__ == "__main__":
try:
main()
except Exception as e:
log("FATAL: " + str(e))
sys.exit(0)
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
sync_wsl.py — WSL 插件/配置批量同步(Win 生效份 -> WSL 生效份)。
用法: python tools/sync_wsl.py [--only 名字 ...]
机制: 每个文件经 `wsl -- bash -c "cat > dst && sha256sum dst"` stdin 管道推送
(绕 UNC OSError 22), 双侧 sha256 校验, 逐个报告, 任一失败退出码 1。
同步清单(按「实际加载路径」对齐, 不是源码路径):
compaction-cn.ts / ai-mcp.ts / oc-retry-guard.ts / model-manager.ts /
oc-session-all/tui.js / mcpcfg.txt
"""
import argparse
import hashlib
import io
import os
import subprocess
import sys
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
DISTRO = "Ubuntu-24.04"
WIN = os.path.join(os.path.expanduser("~"), ".config", "opencode")
ITEMS = [
("compaction-cn", os.path.join(WIN, "plugins", "compaction-cn.ts"),
"/root/.config/opencode/plugins/compaction-cn.ts"),
("ai-mcp", os.path.join(WIN, "plugins", "ai-mcp.ts"),
"/root/.config/opencode/plugins/ai-mcp.ts"),
("tui.js", os.path.join(WIN, "plugins", "oc-session-all", "tui.js"),
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
("mcpcfg", os.path.join(WIN, "mcpcfg.txt"),
"/root/.config/opencode/mcpcfg.txt"),
("oc-retry-guard", os.path.join(WIN, "plugins", "oc-retry-guard.ts"),
"/root/.config/opencode/plugins/oc-retry-guard.ts"),
("model-manager", os.path.join(WIN, "plugins", "model-manager.ts"),
"/root/.config/opencode/plugins/model-manager.ts"),
]
def sha256(b):
return hashlib.sha256(b).hexdigest()
def sync_one(name, local, remote):
if not os.path.exists(local):
print("[%s] SKIP 本地不存在: %s" % (name, local))
return False
data = open(local, "rb").read()
local_sha = sha256(data)
cmd = 'cat > "%s" && sha256sum "%s"' % (remote, remote)
try:
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
input=data, capture_output=True, timeout=120,
)
except Exception as e:
print("[%s] FAIL 启动 wsl.exe 异常: %s" % (name, e))
return False
if r.returncode != 0:
err = (r.stderr or b"").decode("utf-8", errors="replace")[:200]
print("[%s] FAIL push rc=%d %s" % (name, r.returncode, err))
return False
out = r.stdout or b""
text = out.decode("utf-8", errors="replace")
if "sha256" not in text and len(text.split()) < 2:
text = out.decode("utf-16-le", errors="replace")
parts = text.split()
remote_sha = parts[0].strip() if parts else ""
ok = remote_sha == local_sha
print("[%s] %s %d bytes sha=%s" % (name, "OK " if ok else "FAIL", len(data), local_sha[:12]))
return ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", nargs="*", default=None, help="只同步指定名字")
args = ap.parse_args()
items = [it for it in ITEMS if not args.only or it[0] in args.only]
ok = 0
for name, local, remote in items:
if sync_one(name, local, remote):
ok += 1
print("result: %d/%d OK" % (ok, len(items)))
return 0 if ok == len(items) else 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
/**
* OpenCode TUI Plugin: Show All Sessions
*
* Registers /sessions-all command to show all sessions
* across all projects via a searchable picker dialog.
* Selecting a session navigates directly to it.
*
* TUI format: export default { id, tui }
*
* Also intercepts MCP connection error toasts to keep them quiet.
*/
// ── Helpers ────────────────────────────────────────────────────────────
function formatDateTime(ms: number): string {
const d = new Date(ms);
const pad = (v: number) => String(v).padStart(2, "0");
const month = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const hour = pad(d.getHours());
const min = pad(d.getMinutes());
return `${d.getFullYear()}/${month}/${day} ${hour}:${min}`;
}
function projectDisplayName(
session: any,
projectMap: Map<string, any>,
): string {
const project = session.projectID
? projectMap.get(session.projectID)
: undefined;
// Try project name first
if (project?.name?.trim()) return project.name.trim();
// Try worktree basename
const worktree = project?.worktree
?.replace(/\/+$/, "")
.split(/[\\/]/)
.pop();
if (worktree) return worktree;
// Try directory basename
const dir = session.directory
?.replace(/\/+$/, "")
.split(/[\\/]/)
.pop();
if (dir) return dir;
// Fallback to truncated project ID
return session.projectID ? session.projectID.slice(0, 8) : "unknown";
}
// ── Plugin ─────────────────────────────────────────────────────────────
const tui = async (api: any) => {
// ─── MCP Error Toast Suppression ─────────────────────────────────────
// Listen for toast events and suppress MCP-related error toasts.
// This keeps the UI clean when baota-mcp server is offline.
// When the server comes back online, tools auto-reconnect.
api.event.on("tui.toast.show", (event: any) => {
const props = event.properties;
if (
props.variant === "error" &&
typeof props.message === "string" &&
(props.message.toLowerCase().includes("mcp") ||
props.message.toLowerCase().includes("mcp server") ||
props.message.toLowerCase().includes("mcp error") ||
props.message.toLowerCase().includes("failed to connect") ||
props.message.toLowerCase().includes("connection refused"))
) {
// Replace the error message with a silent info toast
props.variant = "info";
props.message = "MCP server offline (auto-reconnect when available)";
props.duration = 2000;
}
});
// ─── Session Picker Dialog ────────────────────────────────────────────
const openPicker = async () => {
// Only work in base mode (not in a permission dialog etc.)
if (api.mode.current() !== "base") return;
let sessions: any[] = [];
let projects: any[] = [];
try {
// Use experimental.session.list to get all sessions across projects
const [sessionResult, projectResult] = await Promise.all([
api.client.experimental.session.list({
limit: 1000,
roots: true,
directory: "",
}),
api.client.project.list({}),
]);
sessions = sessionResult?.data ?? sessionResult ?? [];
projects = projectResult?.data ?? projectResult ?? [];
} catch (err) {
api.ui.toast({
message: "Failed to load sessions",
variant: "error",
});
return;
}
if (!Array.isArray(sessions) || !Array.isArray(projects)) return;
// Build project lookup map
const projectMap = new Map(projects.map((p: any) => [p.id, p]));
// Filter: hide archived and subagent sessions
const visible = sessions.filter(
(s: any) => !s.time?.archived && !s.parentID,
);
if (visible.length === 0) {
api.ui.toast({
message: "No sessions found",
variant: "warning",
});
return;
}
// Group by project
const grouped = new Map<string, any[]>();
for (const session of visible) {
const name = projectDisplayName(session, projectMap);
const list = grouped.get(name) ?? [];
list.push(session);
grouped.set(name, list);
}
// Sort sessions within each project by update time (newest first)
for (const list of grouped.values()) {
list.sort(
(a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0),
);
}
// Sort projects alphabetically
const orderedProjects = [...grouped.keys()].sort((a, b) =>
a.localeCompare(b),
);
// Build dialog options
const options = orderedProjects.flatMap((project) =>
grouped.get(project)!.map((session: any) => {
const updated = session.time?.updated
? formatDateTime(session.time.updated)
: "unknown";
const title = session.title || "Untitled";
return {
title: `${project} ${updated} \u2502 ${title}`,
value: session.id,
category: project,
};
}),
);
// Show the dialog
api.ui.dialog.setSize("xlarge");
api.ui.dialog.replace(
() =>
api.ui.DialogSelect({
title: "All Sessions",
placeholder: "Search sessions across all projects\u2026",
options,
onSelect: (option: any) => {
api.ui.dialog.clear();
api.route.navigate("session", { sessionID: option.value });
},
}),
() => {},
);
};
// ─── Register Command ─────────────────────────────────────────────────
// Register /sessions-all as a slash command
if (api.command) {
api.command.register(() => [
{
title: "All Sessions",
value: "sessions.all",
description: "Browse and switch to any session across all projects",
category: "Session Picker",
slash: { name: "sessions-all", aliases: ["resume-all", "continue-all"] },
onSelect: () => {
void openPicker();
},
},
]);
}
// ─── Keybinding ───────────────────────────────────────────────────────
// Ctrl+o: dedicated fallback keybinding to open the picker
api.keymap.registerLayer({
bindings: [
{
key: "ctrl+o",
desc: "All sessions picker",
preventDefault: true,
cmd: () => void openPicker(),
},
],
});
};
// ── Export ──────────────────────────────────────────────────────────────
export default { id: "oc-session-all", tui };
+101
View File
@@ -0,0 +1,101 @@
import { Plugin } from "@opencode/plugin/tui"
import { appendFileSync, mkdirSync, existsSync } from "fs"
import { join } from "path"
// 日志文件路径
const LOG_DIR = "C:\\Users\\ypshidifu\\.config\\opencode"
const LOG_FILE = join(LOG_DIR, "oc-session-all.log")
// 确保日志目录存在
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true })
}
function log(msg) {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${msg}\n`
try {
appendFileSync(LOG_FILE, line)
} catch (e) {
// 忽略写入错误
}
console.log(msg)
}
export default Plugin.define({
id: "oc-session-all",
setup(context) {
log("[oc-session-all] v2 Plugin loaded! Attempting command registration...")
// ── 探测 context 结构 ──────────────────────────────────────
const report = []
report.push("context keys: " + Object.keys(context).join(", "))
// 探测 context.command
if (context.command) {
const cmdKeys = Object.getOwnPropertyNames(context.command)
report.push("command props: " + cmdKeys.join(", "))
const cmdMethods = cmdKeys.filter(k => typeof context.command[k] === "function")
report.push("command methods: " + cmdMethods.join(", "))
// 尝试 command.register
if (typeof context.command.register === "function") {
try {
const unregister = context.command.register(() => [
{
title: "Show All Sessions",
value: "sessions-all",
slash: { name: "sessions-all", aliases: ["sa"] },
description: "Display all session records in a dialog",
onSelect: async () => {
log("[oc-session-all] /sessions-all triggered")
context.ui.toast.show({
message: "Sessions-all command executed!",
variant: "success",
duration: 3000,
})
},
},
])
report.push("command.register: SUCCESS, unregister type: " + typeof unregister)
log("[oc-session-all] command.register succeeded")
} catch (e) {
report.push("command.register ERROR: " + e.message)
log("[oc-session-all] command.register ERROR: " + e.message)
}
} else {
report.push("command.register: NOT A FUNCTION")
log("[oc-session-all] command.register is not a function")
}
} else {
report.push("context.command: undefined")
log("[oc-session-all] context.command is undefined")
}
// 探测 context.keymap
if (context.keymap) {
const kmKeys = Object.getOwnPropertyNames(context.keymap)
report.push("keymap props: " + kmKeys.join(", "))
const kmMethods = kmKeys.filter(k => typeof context.keymap[k] === "function")
report.push("keymap methods: " + kmMethods.join(", "))
}
// 探测 context.ui
if (context.ui) {
const uiKeys = Object.keys(context.ui)
report.push("ui keys: " + uiKeys.join(", "))
}
const msg = report.join(" | ")
log("[oc-session-all] FULL PROBE: " + msg)
try {
context.ui.toast.show({
message: "Full probe: " + msg,
variant: "info",
duration: 20000,
})
} catch (e) {
log("[oc-session-all] toast.show ERROR: " + e.message)
}
},
})
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
"file:///C:/Users/ypshidifu/.config/opencode/tui-plugins/oc-session-all-debug.js",
"file:///C:/Users/ypshidifu/.config/opencode/tui-plugins/oc-session-all.js"
]
}