# -*- 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 补:插件「状态文件」也要推(修「格式化 xiaomi 后 WSL 仍可见」)── # WSL 侧的 model-manager 读的是 WSL 自己的 cfg/removed/disabled 清单,若不同步, # Win 上「停用/删除」的厂商在 WSL 无从感知 → WSL 仍按旧 catalog 显示 xiaomi/Agnes。 # pristine 不推:6.5MB 巨大,且 WSL 侧若缺时会自动按当前 catalog 重新拍一份,无需强同步。 (os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"), "/root/.config/opencode/model-manager.cfg.json"), (os.path.join(HOME, ".config", "opencode", "model-manager.removed.json"), "/root/.config/opencode/model-manager.removed.json"), (os.path.join(HOME, ".config", "opencode", "model-manager.disabled.json"), "/root/.config/opencode/model-manager.disabled.json"), (os.path.join(HOME, ".config", "opencode", "model-manager.stoplist.bak.json"), "/root/.config/opencode/model-manager.stoplist.bak.json"), ] 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: 返回 (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 生效份插件 -> {dst_path: base64}。""" blob = {} for src, dst in PLUGINS: if not os.path.exists(src): continue try: blob[dst] = base64.b64encode(open(src, "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)