codebee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tutti 自更新:版本读取 / 新版本检测 / 一键升级 / 就地重启。
|
|
3
|
+
|
|
4
|
+
安装模式判定(mode):
|
|
5
|
+
npm — 运行副本位于 node_modules 下且带 package.json(npm 全局安装的包),
|
|
6
|
+
可查新、可升级、可重启;
|
|
7
|
+
repo — 带 .git 的开发仓库:**永不自动升级**(会覆盖开发中的代码),提示走 git pull;
|
|
8
|
+
other — 裸源码拷贝,提示手动替换。
|
|
9
|
+
|
|
10
|
+
升级 = 在标准 mgmt run 里跑 `npm install -g codebee@latest`(日志实时落盘、
|
|
11
|
+
SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进内存不受影响,装完后由
|
|
12
|
+
「重启」换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双 LISTEN
|
|
13
|
+
同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
|
|
14
|
+
|
|
15
|
+
安全:两处子进程命令的 argv 均为**行内字面量列表**(可执行文件与全部参数不来自任何
|
|
16
|
+
外部输入;重启仅透传 argparse 校验过的整型端口),shell 全程 False,绝不拼接用户输入。
|
|
17
|
+
改发布名时(见 test_selfupdate 与 package.json 的一致性断言)同步改 _PKG_NAME。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import socket
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
from . import paths, runner
|
|
31
|
+
|
|
32
|
+
_PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
|
|
33
|
+
_UPDATE_TTL = 600 # 查新结果缓存(秒)
|
|
34
|
+
_LOCK = threading.Lock()
|
|
35
|
+
_CHECK_CACHE = {"ts": 0.0, "result": None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def package_version():
|
|
39
|
+
"""读本包 package.json 的 version;读不到返回 ''(开发仓库未同步版本号时)。"""
|
|
40
|
+
try:
|
|
41
|
+
pj = (paths.ROOT / "package.json").resolve()
|
|
42
|
+
return str(json.loads(pj.read_text(encoding="utf-8")).get("version") or "")
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_mode():
|
|
48
|
+
"""npm / repo / source / other(见模块 docstring)。"""
|
|
49
|
+
try:
|
|
50
|
+
parts = [p.lower() for p in paths.ROOT.resolve().parts]
|
|
51
|
+
except Exception:
|
|
52
|
+
return "other"
|
|
53
|
+
has_pkg = (paths.ROOT / "package.json").is_file()
|
|
54
|
+
if "node_modules" in parts and has_pkg:
|
|
55
|
+
return "npm"
|
|
56
|
+
if (paths.ROOT / ".git").exists():
|
|
57
|
+
return "repo"
|
|
58
|
+
return "source" if has_pkg else "other"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ver_tuple(s):
|
|
62
|
+
return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _npm_latest():
|
|
66
|
+
"""npm view codebee version;返回 (latest, err)。
|
|
67
|
+
用 _PKG_NAME 变量而非行内字面量:发布名改过一次(tutti-orchestrator→codebee),
|
|
68
|
+
硬编码两处容易漏改;Mimosa 安全基线要求 argv 可执行文件为字面量,参数用变量不违反。"""
|
|
69
|
+
r = runner.run_process(
|
|
70
|
+
argv=["cmd", "/c", "npm", "view", _PKG_NAME, "version"], timeout=60)
|
|
71
|
+
if not r["ok"]:
|
|
72
|
+
return "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
|
|
73
|
+
m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
|
|
74
|
+
return (m.group(0) if m else ""), ("" if m else "npm 输出无法解析")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check(force=False):
|
|
78
|
+
"""GET /api/selfupdate 载荷:{mode, current, latest, has_update, note}。"""
|
|
79
|
+
mode = install_mode()
|
|
80
|
+
cur = package_version()
|
|
81
|
+
out = {"mode": mode, "current": cur, "latest": "", "has_update": False, "note": ""}
|
|
82
|
+
if mode != "npm":
|
|
83
|
+
out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
|
|
84
|
+
if mode == "repo" else "非 npm 安装,无法自动更新")
|
|
85
|
+
return out
|
|
86
|
+
with _LOCK:
|
|
87
|
+
if not force and _CHECK_CACHE["result"] and \
|
|
88
|
+
time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
|
|
89
|
+
return dict(_CHECK_CACHE["result"])
|
|
90
|
+
latest, err = _npm_latest()
|
|
91
|
+
out["latest"] = latest
|
|
92
|
+
if err:
|
|
93
|
+
out["note"] = "查询新版本失败:" + err
|
|
94
|
+
elif latest and cur:
|
|
95
|
+
out["has_update"] = _ver_tuple(latest) > _ver_tuple(cur)
|
|
96
|
+
else:
|
|
97
|
+
out["note"] = "无法比较版本(本地或 registry 版本号缺失)"
|
|
98
|
+
with _LOCK:
|
|
99
|
+
_CHECK_CACHE["ts"] = time.time()
|
|
100
|
+
_CHECK_CACHE["result"] = dict(out)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def apply_upgrade():
|
|
105
|
+
"""发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
|
|
106
|
+
if install_mode() != "npm":
|
|
107
|
+
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
108
|
+
from . import store, jobs
|
|
109
|
+
run = store.create_run("mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
110
|
+
entry_id="__self__", op="selfupgrade")
|
|
111
|
+
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
112
|
+
return {"run_id": run["id"]}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_upgrade(run_id, log_path):
|
|
116
|
+
"""worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
|
|
117
|
+
res = runner.run_process(
|
|
118
|
+
argv=["cmd", "/c", "npm", "install", "-g", _PKG_NAME + "@latest"],
|
|
119
|
+
cwd=str(paths.ROOT), timeout=900, log_path=log_path)
|
|
120
|
+
if res["ok"]:
|
|
121
|
+
with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
|
|
122
|
+
_CHECK_CACHE["result"] = None
|
|
123
|
+
return {"ok": res["ok"], "exit_code": res["exit_code"],
|
|
124
|
+
"error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _port_free(port):
|
|
128
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
129
|
+
try:
|
|
130
|
+
s.settimeout(0.3)
|
|
131
|
+
return s.connect_ex(("127.0.0.1", int(port))) != 0
|
|
132
|
+
except Exception:
|
|
133
|
+
return True
|
|
134
|
+
finally:
|
|
135
|
+
s.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def wait_port_before_bind(port):
|
|
139
|
+
"""新进程入口(--wait-port):轮询直到旧实例释放端口(最多 ~30 秒)。
|
|
140
|
+
超时也放行——旧进程若没退,bind 失败自会报错,不会出现双实例串流。"""
|
|
141
|
+
for _ in range(60):
|
|
142
|
+
if _port_free(port):
|
|
143
|
+
return
|
|
144
|
+
time.sleep(0.5)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def relaunch(port):
|
|
148
|
+
"""就地重启:拉起新实例(--wait-port 等新端口可 bind 时旧实例已自退)。
|
|
149
|
+
调用方发送完重启响应后应 self_quit()。port 经 int() 强校验,argv 全字面量。"""
|
|
150
|
+
port = int(port)
|
|
151
|
+
if port < 1 or port > 65535:
|
|
152
|
+
return False
|
|
153
|
+
subprocess.Popen(
|
|
154
|
+
[sys.executable, "main.py", "--port", str(port),
|
|
155
|
+
"--wait-port", "--no-browser"],
|
|
156
|
+
cwd=str(paths.APP_DIR),
|
|
157
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
158
|
+
close_fds=True,
|
|
159
|
+
creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def self_quit():
|
|
164
|
+
"""旧进程体面自退(HTTP 响应已发出后调用)。"""
|
|
165
|
+
try:
|
|
166
|
+
from . import remote
|
|
167
|
+
remote.stop_quick_tunnel()
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
os._exit(0)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Surface 会话日志:只追加事件流 + surface 派生模型输入。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/02-context-compaction.md §1A。
|
|
5
|
+
参考 dsh packages/core/session/src/types.ts:222-271(SessionEvent)与
|
|
6
|
+
surface.ts:140-167(append 同步校验 + replace generation)。
|
|
7
|
+
|
|
8
|
+
核心不变量:
|
|
9
|
+
1. 只追加:append 是唯一写入口,seq 单调,写入即落盘(JSONL)。
|
|
10
|
+
2. 模型可见即已记录:模型输入一律由 derive_messages() 从日志折叠派生,
|
|
11
|
+
不存在"日志外的模型输入"。
|
|
12
|
+
3. surface replace:压缩通过 surface_op={"op":"replace",start,end} 把一段
|
|
13
|
+
事件折叠为单条摘要消息,replace_generation 单调递增(1D 重试守门用)。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionEvent:
|
|
23
|
+
__slots__ = ("seq", "type", "data", "ts", "surface_op", "turn_id")
|
|
24
|
+
|
|
25
|
+
def __init__(self, seq, ev_type, data, ts=None, surface_op=None, turn_id=None):
|
|
26
|
+
self.seq = seq
|
|
27
|
+
self.type = ev_type
|
|
28
|
+
self.data = data
|
|
29
|
+
self.ts = ts if ts is not None else time.time()
|
|
30
|
+
self.surface_op = surface_op
|
|
31
|
+
self.turn_id = turn_id
|
|
32
|
+
|
|
33
|
+
def to_dict(self):
|
|
34
|
+
d = {"seq": self.seq, "type": self.type, "data": self.data, "ts": self.ts}
|
|
35
|
+
if self.surface_op is not None:
|
|
36
|
+
d["surface_op"] = self.surface_op
|
|
37
|
+
if self.turn_id:
|
|
38
|
+
d["turn_id"] = self.turn_id
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, d):
|
|
43
|
+
return cls(d["seq"], d["type"], d.get("data") or {},
|
|
44
|
+
ts=d.get("ts"), surface_op=d.get("surface_op"),
|
|
45
|
+
turn_id=d.get("turn_id"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 进入模型 surface 的事件类型(其余仅记录,不派生)
|
|
49
|
+
_SURFACE_TYPES = ("system_message", "user_message", "assistant_message")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Session:
|
|
53
|
+
"""一次 run 的会话日志。线程安全;落盘路径缺省不持久化(仅内存)。"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, run_id: str, store_path=None):
|
|
56
|
+
self.run_id = run_id
|
|
57
|
+
self.store_path = str(store_path) if store_path else None
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._events = []
|
|
60
|
+
self._seq = 0
|
|
61
|
+
self._replace_generation = 0
|
|
62
|
+
# surface 视图:list[SessionEvent](已折叠);惰性重建
|
|
63
|
+
self._surface = []
|
|
64
|
+
self._surface_dirty = True
|
|
65
|
+
if self.store_path:
|
|
66
|
+
self._load()
|
|
67
|
+
|
|
68
|
+
# ---- 写入 ----
|
|
69
|
+
|
|
70
|
+
def append(self, ev_type, data, *, surface_op=None, turn_id=None):
|
|
71
|
+
"""追加事件。surface_op:
|
|
72
|
+
None -> 追加进 surface(若是 surface 类型)
|
|
73
|
+
"shadow" -> 仅记录,不进 surface(审计/中间产物)
|
|
74
|
+
{"op":"replace","start_seq":N,"end_seq":M} -> 把 [N,M] 折叠为本条
|
|
75
|
+
"""
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._seq += 1
|
|
78
|
+
ev = SessionEvent(self._seq, ev_type, data,
|
|
79
|
+
surface_op=surface_op, turn_id=turn_id)
|
|
80
|
+
self._events.append(ev)
|
|
81
|
+
if isinstance(surface_op, dict) and surface_op.get("op") == "replace":
|
|
82
|
+
self._replace_generation += 1
|
|
83
|
+
self._surface_dirty = True
|
|
84
|
+
self._persist_one(ev)
|
|
85
|
+
return ev
|
|
86
|
+
|
|
87
|
+
# ---- 派生 ----
|
|
88
|
+
|
|
89
|
+
def _fold(self):
|
|
90
|
+
"""按事件序重放 surface。replace 折叠 [start_seq, end_seq] 区间为该条摘要。"""
|
|
91
|
+
surface = [] # list[SessionEvent]
|
|
92
|
+
for ev in self._events:
|
|
93
|
+
op = ev.surface_op
|
|
94
|
+
if op == "shadow":
|
|
95
|
+
continue
|
|
96
|
+
if isinstance(op, dict) and op.get("op") == "replace":
|
|
97
|
+
start, end = op["start_seq"], op["end_seq"]
|
|
98
|
+
surface = [e for e in surface if not (start <= e.seq <= end)]
|
|
99
|
+
surface.append(ev)
|
|
100
|
+
continue
|
|
101
|
+
if ev.type in _SURFACE_TYPES:
|
|
102
|
+
surface.append(ev)
|
|
103
|
+
return surface
|
|
104
|
+
|
|
105
|
+
def derive_messages(self):
|
|
106
|
+
"""模型输入消息列表 [{role, content}]。system 节点去重合并(保留最后一条同 role)。"""
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._surface_dirty:
|
|
109
|
+
self._surface = self._fold()
|
|
110
|
+
self._surface_dirty = False
|
|
111
|
+
snap = list(self._surface)
|
|
112
|
+
msgs = []
|
|
113
|
+
for ev in snap:
|
|
114
|
+
role = "system" if ev.type == "system_message" else (
|
|
115
|
+
"assistant" if ev.type == "assistant_message" else "user")
|
|
116
|
+
msgs.append({"role": role, "content": str(ev.data.get("content", "")),
|
|
117
|
+
"seq": ev.seq})
|
|
118
|
+
# 同 role 连续合并(CLI 提示词偏好单条 system/user)
|
|
119
|
+
merged = []
|
|
120
|
+
for m in msgs:
|
|
121
|
+
if merged and merged[-1]["role"] == m["role"]:
|
|
122
|
+
merged[-1]["content"] += "\n\n" + m["content"]
|
|
123
|
+
merged[-1]["seq"] = m["seq"]
|
|
124
|
+
else:
|
|
125
|
+
merged.append(dict(m))
|
|
126
|
+
return merged
|
|
127
|
+
|
|
128
|
+
def replace_generation(self):
|
|
129
|
+
with self._lock:
|
|
130
|
+
return self._replace_generation
|
|
131
|
+
|
|
132
|
+
def events(self):
|
|
133
|
+
with self._lock:
|
|
134
|
+
return list(self._events)
|
|
135
|
+
|
|
136
|
+
# ---- 持久化 ----
|
|
137
|
+
|
|
138
|
+
def _persist_one(self, ev):
|
|
139
|
+
if not self.store_path:
|
|
140
|
+
return
|
|
141
|
+
with open(self.store_path, "a", encoding="utf-8") as f:
|
|
142
|
+
f.write(json.dumps(ev.to_dict(), ensure_ascii=False) + "\n")
|
|
143
|
+
|
|
144
|
+
def _load(self):
|
|
145
|
+
"""启动恢复:逐行读 JSONL,坏行跳过(lazy skip)。"""
|
|
146
|
+
try:
|
|
147
|
+
with open(self.store_path, encoding="utf-8") as f:
|
|
148
|
+
for line in f:
|
|
149
|
+
line = line.strip()
|
|
150
|
+
if not line.startswith("{"):
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
self._events.append(SessionEvent.from_dict(json.loads(line)))
|
|
154
|
+
except Exception:
|
|
155
|
+
continue
|
|
156
|
+
if self._events:
|
|
157
|
+
self._seq = max(e.seq for e in self._events)
|
|
158
|
+
self._replace_generation = sum(
|
|
159
|
+
1 for e in self._events
|
|
160
|
+
if isinstance(e.surface_op, dict) and e.surface_op.get("op") == "replace")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""本地已有会话扫描:读取各家 CLI 落盘的会话记录,
|
|
3
|
+
供任务"在已有会话上继续"选择。只读解析文件头部,扫描保持轻量。
|
|
4
|
+
|
|
5
|
+
布局(2026-09 本机实测):
|
|
6
|
+
codex : ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl
|
|
7
|
+
首行 type=session_meta(payload.id / cwd);用户消息为
|
|
8
|
+
event_msg(payload.type=user_message, payload.message=...)
|
|
9
|
+
claude : ~/.claude/projects/<项目slug>/<sid>.jsonl
|
|
10
|
+
队列行 {"type":"queue-operation","operation":"enqueue","content":...}
|
|
11
|
+
或消息行 {"type":"user","message":{"content":...}}
|
|
12
|
+
qwen : ~/.qwen/projects/<项目slug>/chats/<sid>.jsonl
|
|
13
|
+
真实用户消息 type=user 且 provenance=real_user,文本在
|
|
14
|
+
message.parts[].text,行上带 cwd 原始项目路径
|
|
15
|
+
opencode / mimo-code : ~/.local/share/{opencode,mimocode}/*.db(SQLite,
|
|
16
|
+
两家同源同 schema)。只读打开(mode=ro,WAL 可并发读),
|
|
17
|
+
session 表取 id/directory/标题,message+part 取首条用户文本。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
SCAN_LIMIT = 12 # 每个智能体最多返回条数
|
|
28
|
+
SCAN_MAX_FILES = 60 # 最多检查的文件数
|
|
29
|
+
HEAD_LINES = 80 # 每个文件最多读取行数
|
|
30
|
+
LINE_CAP = 3000 # 单行读取字符上限
|
|
31
|
+
|
|
32
|
+
# 与 catalog 的 orch.kind 对应的会话根目录
|
|
33
|
+
_CACHE = {"data": None, "ts": 0.0}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def codex_root():
|
|
37
|
+
return Path(os.path.expanduser("~/.codex/sessions"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def claude_root():
|
|
41
|
+
return Path(os.path.expanduser("~/.claude/projects"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def qwen_root():
|
|
45
|
+
return Path(os.path.expanduser("~/.qwen/projects"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def opencode_db():
|
|
49
|
+
return Path(os.path.expanduser("~/.local/share/opencode/opencode.db"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mimo_db():
|
|
53
|
+
return Path(os.path.expanduser("~/.local/share/mimocode/mimocode.db"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_ts(epoch):
|
|
57
|
+
return time.strftime("%m-%d %H:%M", time.localtime(epoch))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _preview_cut(text):
|
|
61
|
+
text = " ".join(str(text or "").split())
|
|
62
|
+
for prefix in ("<user_instructions", "<permissions", "<ENVIRONMENT", "<environment_context"):
|
|
63
|
+
if text.startswith(prefix):
|
|
64
|
+
return ""
|
|
65
|
+
return text[:140]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _scan_codex():
|
|
69
|
+
root = codex_root()
|
|
70
|
+
if not root.is_dir():
|
|
71
|
+
return []
|
|
72
|
+
files = sorted(root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
73
|
+
out = []
|
|
74
|
+
for f in files[:SCAN_MAX_FILES]:
|
|
75
|
+
sid, cwd, preview, turn_count = "", "", "", 0
|
|
76
|
+
try:
|
|
77
|
+
with open(f, encoding="utf-8", errors="replace") as fp:
|
|
78
|
+
for i, raw in enumerate(fp):
|
|
79
|
+
if i >= HEAD_LINES:
|
|
80
|
+
break
|
|
81
|
+
line = raw.strip()
|
|
82
|
+
if not line.startswith("{"):
|
|
83
|
+
continue
|
|
84
|
+
is_meta = '"session_meta"' in line[:300]
|
|
85
|
+
# meta 行可能很长(含 base_instructions),完整解析;其余超长行截断尽力解析
|
|
86
|
+
try:
|
|
87
|
+
ev = json.loads(line if (is_meta or len(line) < 20000) else line[:LINE_CAP])
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
if ev.get("type") == "session_meta":
|
|
91
|
+
payload = ev.get("payload") or {}
|
|
92
|
+
sid = payload.get("id") or sid
|
|
93
|
+
cwd = payload.get("cwd") or cwd
|
|
94
|
+
elif ev.get("type") == "event_msg" and (ev.get("payload") or {}).get("type") == "user_message":
|
|
95
|
+
turn_count += 1
|
|
96
|
+
if not preview:
|
|
97
|
+
preview = _preview_cut((ev.get("payload") or {}).get("message"))
|
|
98
|
+
elif ev.get("type") == "response_item" and (ev.get("payload") or {}).get("role") == "user":
|
|
99
|
+
if not preview:
|
|
100
|
+
for c in (ev["payload"].get("content") or []):
|
|
101
|
+
if isinstance(c, dict) and c.get("type") == "input_text":
|
|
102
|
+
pv = _preview_cut(c.get("text"))
|
|
103
|
+
if pv:
|
|
104
|
+
preview = pv
|
|
105
|
+
break
|
|
106
|
+
if sid and cwd and turn_count > 2:
|
|
107
|
+
break
|
|
108
|
+
except Exception:
|
|
109
|
+
continue
|
|
110
|
+
out.append({
|
|
111
|
+
"agent": "codex-cli",
|
|
112
|
+
"session_id": sid or f.stem,
|
|
113
|
+
"file": str(f),
|
|
114
|
+
"project": cwd,
|
|
115
|
+
"mtime": _fmt_ts(f.stat().st_mtime),
|
|
116
|
+
"turns": turn_count,
|
|
117
|
+
"preview": preview or "(未解析到用户消息)",
|
|
118
|
+
})
|
|
119
|
+
if len(out) >= SCAN_LIMIT:
|
|
120
|
+
break
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scan_claude():
|
|
125
|
+
root = claude_root()
|
|
126
|
+
if not root.is_dir():
|
|
127
|
+
return []
|
|
128
|
+
files = []
|
|
129
|
+
for proj in root.iterdir():
|
|
130
|
+
if proj.is_dir():
|
|
131
|
+
files.extend(proj.glob("*.jsonl"))
|
|
132
|
+
files = sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
133
|
+
out = []
|
|
134
|
+
for f in files[:SCAN_MAX_FILES]:
|
|
135
|
+
preview, msg_count, cwd = "", 0, ""
|
|
136
|
+
try:
|
|
137
|
+
with open(f, encoding="utf-8", errors="replace") as fp:
|
|
138
|
+
for i, raw in enumerate(fp):
|
|
139
|
+
if i >= HEAD_LINES:
|
|
140
|
+
break
|
|
141
|
+
line = raw[:LINE_CAP]
|
|
142
|
+
if not line.startswith("{"):
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
ev = json.loads(line)
|
|
146
|
+
except Exception:
|
|
147
|
+
continue
|
|
148
|
+
# 行上的 cwd 是真实项目路径(目录名只是转义后的 slug,不能当路径用)
|
|
149
|
+
if not cwd and ev.get("cwd"):
|
|
150
|
+
cwd = ev["cwd"]
|
|
151
|
+
if ev.get("type") == "queue-operation" and ev.get("operation") == "enqueue":
|
|
152
|
+
if not preview:
|
|
153
|
+
preview = _preview_cut(ev.get("content"))
|
|
154
|
+
elif ev.get("type") == "user":
|
|
155
|
+
msg_count += 1
|
|
156
|
+
msg = (ev.get("message") or {})
|
|
157
|
+
content = msg.get("content")
|
|
158
|
+
if isinstance(content, list):
|
|
159
|
+
content = " ".join(
|
|
160
|
+
b.get("text", "") for b in content if isinstance(b, dict))
|
|
161
|
+
if not preview:
|
|
162
|
+
pv = _preview_cut(content)
|
|
163
|
+
if pv and not str(content or "").startswith("<"):
|
|
164
|
+
preview = pv
|
|
165
|
+
if preview and msg_count > 1 and cwd:
|
|
166
|
+
break
|
|
167
|
+
except Exception:
|
|
168
|
+
continue
|
|
169
|
+
out.append({
|
|
170
|
+
"agent": "claude-code",
|
|
171
|
+
"session_id": f.stem,
|
|
172
|
+
"file": str(f),
|
|
173
|
+
"project": cwd or f.parent.name,
|
|
174
|
+
"mtime": _fmt_ts(f.stat().st_mtime),
|
|
175
|
+
"turns": msg_count,
|
|
176
|
+
"preview": preview or "(未解析到用户消息)",
|
|
177
|
+
})
|
|
178
|
+
if len(out) >= SCAN_LIMIT:
|
|
179
|
+
break
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _scan_opencode_db(db_path, agent):
|
|
184
|
+
"""opencode 系会话库(opencode / mimocode 同源同 schema)。
|
|
185
|
+
|
|
186
|
+
只读打开(mode=ro 不阻塞 WAL);每会话取标题兜底,再找第一条
|
|
187
|
+
用户消息的首个文本 part 做预览。
|
|
188
|
+
"""
|
|
189
|
+
if not db_path.is_file():
|
|
190
|
+
return []
|
|
191
|
+
uri = "file:%s?mode=ro" % str(db_path).replace("\\", "/")
|
|
192
|
+
try:
|
|
193
|
+
con = sqlite3.connect(uri, uri=True)
|
|
194
|
+
except Exception:
|
|
195
|
+
return []
|
|
196
|
+
out = []
|
|
197
|
+
try:
|
|
198
|
+
rows = con.execute(
|
|
199
|
+
"SELECT id, directory, title, time_updated FROM session "
|
|
200
|
+
"WHERE time_archived IS NULL "
|
|
201
|
+
"ORDER BY time_updated DESC LIMIT ?", (SCAN_LIMIT,)).fetchall()
|
|
202
|
+
for sid, directory, title, ts in rows:
|
|
203
|
+
preview = _preview_cut(title)
|
|
204
|
+
try:
|
|
205
|
+
for mid, mdata in con.execute(
|
|
206
|
+
"SELECT id, data FROM message WHERE session_id=? "
|
|
207
|
+
"ORDER BY time_created LIMIT 6", (sid,)):
|
|
208
|
+
if json.loads(mdata).get("role") != "user":
|
|
209
|
+
continue
|
|
210
|
+
texts = []
|
|
211
|
+
for (pdata,) in con.execute(
|
|
212
|
+
"SELECT data FROM part WHERE session_id=? AND message_id=? "
|
|
213
|
+
"ORDER BY time_created LIMIT 4", (sid, mid)):
|
|
214
|
+
p = json.loads(pdata)
|
|
215
|
+
if isinstance(p, dict) and p.get("text"):
|
|
216
|
+
texts.append(p["text"])
|
|
217
|
+
pv = _preview_cut(" ".join(texts))
|
|
218
|
+
if pv:
|
|
219
|
+
preview = pv
|
|
220
|
+
break
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
out.append({
|
|
224
|
+
"agent": agent,
|
|
225
|
+
"session_id": sid,
|
|
226
|
+
"file": str(db_path),
|
|
227
|
+
"project": directory or "",
|
|
228
|
+
"mtime": _fmt_ts(ts / 1000.0) if ts else "",
|
|
229
|
+
"preview": preview or "(未解析到用户消息)",
|
|
230
|
+
})
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
finally:
|
|
234
|
+
try:
|
|
235
|
+
con.close()
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _scan_qwen():
|
|
242
|
+
"""QwenCode(gemini-cli 系)会话:~/.qwen/projects/<slug>/chats/<sid>.jsonl,
|
|
243
|
+
文件名即会话 id(--resume 直接用它)。"""
|
|
244
|
+
root = qwen_root()
|
|
245
|
+
if not root.is_dir():
|
|
246
|
+
return []
|
|
247
|
+
files = sorted(root.glob("*/chats/*.jsonl"),
|
|
248
|
+
key=lambda p: p.stat().st_mtime, reverse=True)
|
|
249
|
+
out = []
|
|
250
|
+
for f in files[:SCAN_MAX_FILES]:
|
|
251
|
+
preview, project, turns = "", "", 0
|
|
252
|
+
try:
|
|
253
|
+
with open(f, encoding="utf-8", errors="replace") as fp:
|
|
254
|
+
for i, raw in enumerate(fp):
|
|
255
|
+
if i >= HEAD_LINES:
|
|
256
|
+
break
|
|
257
|
+
line = raw.rstrip("\n")
|
|
258
|
+
if len(line) > 2_000_000: # 病理超长行跳过,不做解析
|
|
259
|
+
continue
|
|
260
|
+
if not line.startswith("{"):
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
ev = json.loads(line)
|
|
264
|
+
except Exception:
|
|
265
|
+
continue
|
|
266
|
+
if ev.get("type") != "user":
|
|
267
|
+
continue
|
|
268
|
+
if ev.get("provenance") not in (None, "real_user"):
|
|
269
|
+
continue
|
|
270
|
+
turns += 1
|
|
271
|
+
if not project and ev.get("cwd"):
|
|
272
|
+
project = ev["cwd"]
|
|
273
|
+
if not preview:
|
|
274
|
+
msg = ev.get("message") or {}
|
|
275
|
+
texts = [p.get("text", "") for p in (msg.get("parts") or [])
|
|
276
|
+
if isinstance(p, dict) and p.get("text")]
|
|
277
|
+
pv = _preview_cut(" ".join(texts))
|
|
278
|
+
if pv:
|
|
279
|
+
preview = pv
|
|
280
|
+
if preview and turns > 1:
|
|
281
|
+
break
|
|
282
|
+
except Exception:
|
|
283
|
+
continue
|
|
284
|
+
out.append({
|
|
285
|
+
"agent": "qwencode",
|
|
286
|
+
"session_id": f.stem,
|
|
287
|
+
"file": str(f),
|
|
288
|
+
"project": project or f.parent.parent.name,
|
|
289
|
+
"mtime": _fmt_ts(f.stat().st_mtime),
|
|
290
|
+
"turns": turns,
|
|
291
|
+
"preview": preview or "(未解析到用户消息)",
|
|
292
|
+
})
|
|
293
|
+
if len(out) >= SCAN_LIMIT:
|
|
294
|
+
break
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def scan(force=False):
|
|
299
|
+
"""扫描全部支持的会话源。60 秒缓存;键为 catalog 的智能体 id,
|
|
300
|
+
恒定包含五个已知源(无会话则空列表),UI 由此判断哪些工具可选。"""
|
|
301
|
+
if not force and _CACHE["data"] and time.time() - _CACHE["ts"] < 60:
|
|
302
|
+
return _CACHE["data"]
|
|
303
|
+
data = {
|
|
304
|
+
"codex-cli": _scan_codex(),
|
|
305
|
+
"claude-code": _scan_claude(),
|
|
306
|
+
"opencode": _scan_opencode_db(opencode_db(), "opencode"),
|
|
307
|
+
"qwencode": _scan_qwen(),
|
|
308
|
+
"mimo-code": _scan_opencode_db(mimo_db(), "mimo-code"),
|
|
309
|
+
}
|
|
310
|
+
_CACHE["data"] = data
|
|
311
|
+
_CACHE["ts"] = time.time()
|
|
312
|
+
return data
|