Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49ff8ef36 | ||
|
|
a41cf92379 | ||
|
|
546fc07779 | ||
|
|
6b65ec50e7 | ||
|
|
f2b7303cd2 | ||
|
|
3c768343e4 | ||
|
|
afdafb0ed1 | ||
|
|
5cd94dc4e1 | ||
|
|
f7688bf27e | ||
|
|
884d53b619 | ||
|
|
b911a43940 | ||
|
|
19e16a42e0 | ||
|
|
874e69fb14 | ||
|
|
cd25c0a3d0 | ||
|
|
0001fc7d64 | ||
|
|
7ab1b9a9b0 | ||
|
|
38c2b20e3d | ||
|
|
aa9b3281f1 | ||
|
|
8e07bd8af8 | ||
|
|
2f64df6a9b | ||
|
|
90eb09b2a4 | ||
|
|
fd8cfa8887 | ||
|
|
043fd55ae1 | ||
|
|
0d2d1144eb | ||
|
|
250e71c48c | ||
|
|
b7537988f9 | ||
|
|
9dfe04f221 | ||
|
|
fcf188bf28 | ||
|
|
2f91a612bb | ||
|
|
ee4be54d0f | ||
|
|
4431ed8335 | ||
|
|
1ba3ad6bd3 | ||
|
|
c593f94a5e | ||
|
|
6215144fb5 | ||
|
|
0f099c88d0 | ||
|
|
6e0ed1743a | ||
|
|
20a3b487fd | ||
|
|
b65d3d6879 | ||
|
|
831e3de8d8 | ||
|
|
ffc0e8d743 | ||
|
|
3c0a8971a6 | ||
|
|
b74d87cfbb | ||
|
|
c1cb45bed4 | ||
|
|
d6048da95f | ||
|
|
92fee7511f | ||
|
|
6d59c24126 | ||
|
|
f66cde6494 | ||
|
|
ba26fd512a | ||
|
|
3d8f093d7e | ||
|
|
459f038f8e | ||
|
|
fd1a475abc | ||
|
|
dc1a27e88e | ||
|
|
ec7b461147 | ||
|
|
39efb3aa0f | ||
|
|
9f17ac9936 | ||
|
|
057e698f22 | ||
|
|
028af01b3d | ||
|
|
952b31127c | ||
|
|
a5a8c5e011 | ||
|
|
76d641b17f | ||
|
|
6c165dadd5 | ||
|
|
47c668f446 | ||
|
|
d201e3bb97 | ||
|
|
6bbbc5ea0b | ||
|
|
258a40b0d0 | ||
|
|
6c4d97362f | ||
|
|
bfe683d7e2 | ||
|
|
37df56c6d9 | ||
|
|
289ff13132 | ||
|
|
45e68bfecf | ||
|
|
0d19143065 | ||
|
|
5d43b5ed76 | ||
|
|
02f3ded256 |
+22
@@ -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
|
||||
@@ -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的连接通道) |
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
Generated
+422
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user