diff --git a/tools/oc-model-sync.py b/tools/oc-model-sync.py index de3b377..295fbea 100644 --- a/tools/oc-model-sync.py +++ b/tools/oc-model-sync.py @@ -66,18 +66,15 @@ PLUGINS = [ "/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 重新拍一份,无需强同步。 + # ── 2026-10-20 治本:provider 配置同步(替代 model-manager 压制清单)── + # cc-switch 把活跃 provider(如 agnes)注入到 opencode.json.2; + # 把整个 opencode.json.2 推给 WSL,让 WSL OC 读到一致的 provider 状态。 + # 同步后 WSL 侧的 model-manager 不再需要 removed/disabled 清单压制 agnes, + # 因为 cc-switch 删 agnes → Win opencode.json.2 更新 → WSL 同步 → agnes 消失。 + (os.path.join(HOME, ".config", "opencode", "opencode.json.2"), + "/root/.config/opencode/opencode.json.2"), (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"), ] @@ -109,7 +106,7 @@ def fingerprint(): def read_win_data(): - """只读 Win db: 返回 (catalog_value, credentials_json, n_creds)。""" + """只读 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: @@ -125,7 +122,13 @@ def read_win_data(): def build_plugin_blob(): - """Win 生效份插件 -> {dst_path: base64}。""" + """Win 生效份插件 + opencode.json.2 -> {dst_path: base64}。 + + 额外:把 WSL 侧 model-manager 压制清单(removed/disabled/stoplist.bak)重置为空, + 因为 opencode.json.2 里 provider 已同步到 WSL,不再需要靠压制清单删厂商。 + 若 Win 侧清单非空(比如用户还在用 model-manager 手动压制某厂商), + 则把 Win 的清单同步过去,保持两边压制行为一致。 + """ blob = {} for src, dst in PLUGINS: if not os.path.exists(src): @@ -134,6 +137,16 @@ def build_plugin_blob(): blob[dst] = base64.b64encode(open(src, "rb").read()).decode("ascii") except Exception: pass + # 把 model-manager 压制清单也同步(Win 非空就推,空就推空,保证 WSL 与 Win 对齐) + for fname in ["model-manager.removed.json", "model-manager.disabled.json", + "model-manager.stoplist.bak.json"]: + src = os.path.join(HOME, ".config", "opencode", fname) + dst = "/root/.config/opencode/" + fname + if os.path.exists(src): + try: + blob[dst] = base64.b64encode(open(src, "rb").read()).decode("ascii") + except Exception: + pass return blob diff --git a/tools/oc-session-sync.py b/tools/oc-session-sync.py new file mode 100644 index 0000000..44ce83b --- /dev/null +++ b/tools/oc-session-sync.py @@ -0,0 +1,354 @@ +# -*- 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"] + + +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 最新 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), + ) + ] + 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)