codebee 0.1.95 → 0.1.96
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/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/sessions.py
CHANGED
|
@@ -1,312 +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
|
|
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
|