@clawos-dev/clawd 0.2.274 → 0.2.276
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/dist/cli.cjs +80 -65
- package/dist/dispatch/mcp-server.cjs +6 -1
- package/dist/persona-defaults/persona-app-builder/extension-kit/README.md +14 -1
- package/dist/persona-defaults/persona-clawd-butler/.claude/skills/clawd-session-import/SKILL.md +105 -0
- package/dist/persona-defaults/persona-clawd-butler/.claude/skills/clawd-session-import/scripts/link-sessions.mjs +665 -0
- package/dist/persona-defaults/persona-clawd-butler/CLAUDE.md +2 -1
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +14 -1
- package/dist/share-md-viewer-error.html +1 -1
- package/dist/share-md-viewer.html +1 -1
- package/dist/share-ui/assets/{guest-DB_Gozzs.css → guest-BKKB9cOG.css} +1 -1
- package/dist/share-ui/assets/{guest-BKO48Nz8.js → guest-XaN4r7jJ.js} +1 -1
- package/dist/share-ui/guest.html +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 把本机 Claude Code / Codex 的历史会话关联成 clawd session。
|
|
3
|
+
//
|
|
4
|
+
// 原理(对 clawd 源码核实过,非猜测):
|
|
5
|
+
// - session:create 只写一份 SessionFile 元数据,不拉起任何进程 → 批量关联很便宜
|
|
6
|
+
// - session:resume 在该 session 尚无 runner 时,只是把 toolSessionId 写进元数据
|
|
7
|
+
// - SessionStore.list() 每次都从磁盘重读 → 事后回填 createdAt/updatedAt 即时生效
|
|
8
|
+
// 关联完,会话出现在 clawd sidebar;点开时由 history reader 按 (cwd, toolSessionId)
|
|
9
|
+
// 渲染原始 transcript;用户发消息才真正 resume 起 CC / codex 进程接着聊。
|
|
10
|
+
//
|
|
11
|
+
// 不复制、不搬运任何 transcript 文件——只建立指向原文件的引用,原会话照常可用。
|
|
12
|
+
//
|
|
13
|
+
// 用法:
|
|
14
|
+
// node link-sessions.mjs # dry-run,列出候选
|
|
15
|
+
// node link-sessions.mjs --apply # 真正关联
|
|
16
|
+
// node link-sessions.mjs --apply --limit 20 # 只关联最近 20 条
|
|
17
|
+
// node link-sessions.mjs --undo --apply # 撤销本工具关联过的全部会话
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { createRequire } from 'node:module'
|
|
24
|
+
|
|
25
|
+
// node:sqlite 是实验特性,会打一行 ExperimentalWarning,对用户是噪音,摘掉
|
|
26
|
+
process.removeAllListeners('warning')
|
|
27
|
+
process.on('warning', (w) => {
|
|
28
|
+
if (w.name !== 'ExperimentalWarning') console.warn(w.message)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const HOME = os.homedir()
|
|
32
|
+
const CLAWD_DIR = path.join(HOME, '.clawd')
|
|
33
|
+
const RPC_CFG = path.join(CLAWD_DIR, 'rpc-tool.mcp.json')
|
|
34
|
+
const LEDGER = path.join(CLAWD_DIR, 'session-import-ledger.json')
|
|
35
|
+
const CLAUDE_PROJECTS = path.join(HOME, '.claude', 'projects')
|
|
36
|
+
const CODEX_DIR = path.join(HOME, '.codex')
|
|
37
|
+
|
|
38
|
+
class RpcError extends Error {}
|
|
39
|
+
|
|
40
|
+
function die(msg) {
|
|
41
|
+
console.error(msg)
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------- 小工具
|
|
46
|
+
|
|
47
|
+
function clip(text, n) {
|
|
48
|
+
const s = String(text ?? '').split(/\s+/).filter(Boolean).join(' ')
|
|
49
|
+
return s.length <= n ? s : s.slice(0, n - 1) + '…'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 中文字符占两列,纯 length 对不齐
|
|
53
|
+
function width(s) {
|
|
54
|
+
let w = 0
|
|
55
|
+
for (const ch of s) w += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
|
|
56
|
+
return w
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pad(s, n) {
|
|
60
|
+
return s + ' '.repeat(Math.max(0, n - width(s)))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 按显示宽度截断(中文按 2 列算),否则表格列会被撑歪。
|
|
64
|
+
// 顺带把换行 / 连续空白折叠成单空格——标题是单行的,不折叠会打乱终端输出和侧边栏
|
|
65
|
+
function clipW(text, n) {
|
|
66
|
+
const s = String(text ?? '').split(/\s+/).filter(Boolean).join(' ')
|
|
67
|
+
if (width(s) <= n) return s
|
|
68
|
+
let out = ''
|
|
69
|
+
let w = 0
|
|
70
|
+
for (const ch of s) {
|
|
71
|
+
const cw = width(ch)
|
|
72
|
+
if (w + cw > n - 1) break
|
|
73
|
+
out += ch
|
|
74
|
+
w += cw
|
|
75
|
+
}
|
|
76
|
+
return out + '…'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isoOf(ms) {
|
|
80
|
+
return new Date(ms).toISOString()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// codex 各版本的时间列有的是秒有的是毫秒,按量级判
|
|
84
|
+
function toMs(v) {
|
|
85
|
+
const n = Number(v || 0)
|
|
86
|
+
return n > 1e11 ? Math.round(n) : Math.round(n * 1000)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function listFiles(dir, depth = 0, maxDepth = 8, out = []) {
|
|
90
|
+
let entries
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
93
|
+
} catch {
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const full = path.join(dir, e.name)
|
|
98
|
+
if (e.isDirectory()) {
|
|
99
|
+
if (depth < maxDepth) listFiles(full, depth + 1, maxDepth, out)
|
|
100
|
+
} else if (e.isFile()) {
|
|
101
|
+
out.push(full)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function* jsonLines(file) {
|
|
108
|
+
let raw
|
|
109
|
+
try {
|
|
110
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
for (const line of raw.split('\n')) {
|
|
115
|
+
const t = line.trim()
|
|
116
|
+
if (!t) continue
|
|
117
|
+
try {
|
|
118
|
+
yield JSON.parse(t)
|
|
119
|
+
} catch {
|
|
120
|
+
/* 坏行跳过 */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- RPC
|
|
126
|
+
|
|
127
|
+
function rpcConfig() {
|
|
128
|
+
try {
|
|
129
|
+
const cfg = JSON.parse(fs.readFileSync(RPC_CFG, 'utf8'))
|
|
130
|
+
const env = cfg.mcpServers['clawd-rpc'].env
|
|
131
|
+
return { url: env.CLAWD_DAEMON_URL.replace(/\/+$/, ''), token: env.CLAWD_DAEMON_TOKEN }
|
|
132
|
+
} catch (err) {
|
|
133
|
+
die(`读不到 clawd 的本地接口配置(${RPC_CFG}):${err.message}。clawd 装好并启动过吗?`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let RPC = null
|
|
138
|
+
|
|
139
|
+
async function rpc(method, args = {}) {
|
|
140
|
+
RPC ||= rpcConfig()
|
|
141
|
+
let resp
|
|
142
|
+
try {
|
|
143
|
+
resp = await fetch(`${RPC.url}/rpc/${method}`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${RPC.token}` },
|
|
146
|
+
body: JSON.stringify(args),
|
|
147
|
+
signal: AbortSignal.timeout(30000),
|
|
148
|
+
})
|
|
149
|
+
} catch (err) {
|
|
150
|
+
die(`连不上 clawd(${RPC.url}):${err.message}。请先启动 Clawd 桌面端。`)
|
|
151
|
+
}
|
|
152
|
+
const text = await resp.text()
|
|
153
|
+
if (!resp.ok) throw new RpcError(`${method} → HTTP ${resp.status}: ${text.slice(0, 300)}`)
|
|
154
|
+
let body
|
|
155
|
+
try {
|
|
156
|
+
body = JSON.parse(text)
|
|
157
|
+
} catch {
|
|
158
|
+
throw new RpcError(`${method} 返回的不是 JSON:${text.slice(0, 200)}`)
|
|
159
|
+
}
|
|
160
|
+
// daemon 的 HTTP 出口统一包一层 {ok, result} / {ok:false, error}
|
|
161
|
+
if (body && typeof body === 'object' && 'ok' in body) {
|
|
162
|
+
if (!body.ok) throw new RpcError(`${method} 被拒:${JSON.stringify(body.error).slice(0, 300)}`)
|
|
163
|
+
return body.result ?? {}
|
|
164
|
+
}
|
|
165
|
+
return body
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------- Claude Code 扫描
|
|
169
|
+
|
|
170
|
+
// 真实用户输入的文本;tool_result / 图片等块返回空串
|
|
171
|
+
function userText(msg) {
|
|
172
|
+
const content = msg && typeof msg === 'object' ? msg.content : null
|
|
173
|
+
if (typeof content === 'string') return content
|
|
174
|
+
if (Array.isArray(content)) {
|
|
175
|
+
return content.map((b) => (b && b.type === 'text' ? b.text || '' : '')).join('')
|
|
176
|
+
}
|
|
177
|
+
return ''
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ~/.claude/projects/<编码后的 cwd>/<toolSessionId>.jsonl —— 文件名即 CC session id
|
|
181
|
+
function scanClaude(cutoffMs) {
|
|
182
|
+
const out = []
|
|
183
|
+
let dirs
|
|
184
|
+
try {
|
|
185
|
+
dirs = fs.readdirSync(CLAUDE_PROJECTS, { withFileTypes: true })
|
|
186
|
+
} catch {
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
for (const d of dirs) {
|
|
190
|
+
if (!d.isDirectory()) continue
|
|
191
|
+
let files
|
|
192
|
+
try {
|
|
193
|
+
files = fs.readdirSync(path.join(CLAUDE_PROJECTS, d.name))
|
|
194
|
+
} catch {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
for (const name of files) {
|
|
198
|
+
if (!name.endsWith('.jsonl')) continue
|
|
199
|
+
const file = path.join(CLAUDE_PROJECTS, d.name, name)
|
|
200
|
+
let st
|
|
201
|
+
try {
|
|
202
|
+
st = fs.statSync(file)
|
|
203
|
+
} catch {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (st.mtimeMs < cutoffMs) continue
|
|
207
|
+
|
|
208
|
+
let cwd = ''
|
|
209
|
+
let title = ''
|
|
210
|
+
let firstUser = ''
|
|
211
|
+
let turns = 0
|
|
212
|
+
let firstTs = 0
|
|
213
|
+
let lastTs = 0
|
|
214
|
+
let hasMainLine = false // 有非 sidechain 的正文行 = 不是 subagent 支线文件
|
|
215
|
+
|
|
216
|
+
for (const rec of jsonLines(file)) {
|
|
217
|
+
if (!cwd && typeof rec.cwd === 'string') cwd = rec.cwd
|
|
218
|
+
if (typeof rec.timestamp === 'string') {
|
|
219
|
+
const t = Date.parse(rec.timestamp)
|
|
220
|
+
if (Number.isFinite(t)) {
|
|
221
|
+
firstTs ||= t
|
|
222
|
+
lastTs = t
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (rec.type === 'ai-title' && rec.aiTitle) {
|
|
226
|
+
title = rec.aiTitle // CC 自己生成的标题,最适合当 label
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (rec.type !== 'user' && rec.type !== 'assistant') continue
|
|
230
|
+
if (!rec.isSidechain) hasMainLine = true
|
|
231
|
+
if (rec.type !== 'user' || rec.isSidechain || rec.isMeta) continue
|
|
232
|
+
const text = userText(rec.message || {})
|
|
233
|
+
if (!text.trim() || text.trimStart().startsWith('<')) continue // <command-*> 等系统注入
|
|
234
|
+
turns += 1
|
|
235
|
+
firstUser ||= text
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const cand = {
|
|
239
|
+
tool: 'claude',
|
|
240
|
+
toolSessionId: name.slice(0, -'.jsonl'.length),
|
|
241
|
+
cwd,
|
|
242
|
+
// 优先 CC 生成的 ai-title,没有就拿第一句用户发言硬截
|
|
243
|
+
label: clipW(title || firstUser, 56),
|
|
244
|
+
turns,
|
|
245
|
+
createdMs: firstTs || st.mtimeMs,
|
|
246
|
+
updatedMs: lastTs || st.mtimeMs,
|
|
247
|
+
skip: '',
|
|
248
|
+
result: '',
|
|
249
|
+
path: file,
|
|
250
|
+
}
|
|
251
|
+
if (!hasMainLine) cand.skip = 'subagent 支线'
|
|
252
|
+
else if (!cwd) cand.skip = '无 cwd'
|
|
253
|
+
out.push(cand)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------- Codex 扫描
|
|
260
|
+
|
|
261
|
+
// 从 rollout jsonl 数真实用户轮次,顺带取第一条用户发言
|
|
262
|
+
function countCodexTurns(rolloutPath) {
|
|
263
|
+
if (!rolloutPath || !fs.existsSync(rolloutPath)) return { turns: 0, first: '' }
|
|
264
|
+
let turns = 0
|
|
265
|
+
let first = ''
|
|
266
|
+
for (const rec of jsonLines(rolloutPath)) {
|
|
267
|
+
const p = rec.payload || {}
|
|
268
|
+
if (p.type !== 'message' || p.role !== 'user') continue
|
|
269
|
+
const text = (p.content || []).map((b) => (b && b.text) || '').join('')
|
|
270
|
+
if (!text.trim() || text.trimStart().startsWith('<')) continue // environment_context 等注入
|
|
271
|
+
turns += 1
|
|
272
|
+
first ||= text
|
|
273
|
+
}
|
|
274
|
+
return { turns, first }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 读 threads 表:优先 node:sqlite,退而 sqlite3 CLI,都没有则返回 null 让上层回落解析 rollout
|
|
278
|
+
function readCodexThreads(db) {
|
|
279
|
+
const COLS = ['id', 'cwd', 'created_at', 'updated_at', 'name', 'title', 'preview',
|
|
280
|
+
'first_user_message', 'archived', 'rollout_path']
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
// node:sqlite 在 Node 22.5 以下不存在,require 会直接抛,落到下面的 sqlite3 CLI
|
|
284
|
+
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite')
|
|
285
|
+
const conn = new DatabaseSync(db, { readOnly: true })
|
|
286
|
+
try {
|
|
287
|
+
const have = new Set(conn.prepare('PRAGMA table_info(threads)').all().map((r) => r.name))
|
|
288
|
+
if (!['id', 'cwd', 'updated_at'].every((c) => have.has(c))) return null
|
|
289
|
+
const sel = COLS.map((c) => (have.has(c) ? c : `NULL AS ${c}`)).join(', ')
|
|
290
|
+
return conn.prepare(`SELECT ${sel} FROM threads`).all()
|
|
291
|
+
} finally {
|
|
292
|
+
conn.close()
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
/* 落到 sqlite3 CLI */
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const info = spawnSync('sqlite3', [db, 'PRAGMA table_info(threads);'], { encoding: 'utf8' })
|
|
300
|
+
if (info.status !== 0) return null
|
|
301
|
+
const have = new Set(info.stdout.split('\n').map((l) => l.split('|')[1]).filter(Boolean))
|
|
302
|
+
if (!['id', 'cwd', 'updated_at'].every((c) => have.has(c))) return null
|
|
303
|
+
const sel = COLS.map((c) => (have.has(c) ? c : `NULL AS ${c}`)).join(', ')
|
|
304
|
+
const res = spawnSync('sqlite3', ['-json', db, `SELECT ${sel} FROM threads;`], {
|
|
305
|
+
encoding: 'utf8',
|
|
306
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
307
|
+
})
|
|
308
|
+
if (res.status !== 0 || !res.stdout.trim()) return null
|
|
309
|
+
return JSON.parse(res.stdout)
|
|
310
|
+
} catch {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function scanCodex(cutoffMs) {
|
|
316
|
+
let dbs = []
|
|
317
|
+
try {
|
|
318
|
+
dbs = fs.readdirSync(CODEX_DIR)
|
|
319
|
+
.filter((n) => /^state_.*\.sqlite$/.test(n))
|
|
320
|
+
.sort()
|
|
321
|
+
.map((n) => path.join(CODEX_DIR, n))
|
|
322
|
+
} catch {
|
|
323
|
+
/* 没装 codex */
|
|
324
|
+
}
|
|
325
|
+
if (dbs.length) {
|
|
326
|
+
const rows = readCodexThreads(dbs[dbs.length - 1])
|
|
327
|
+
if (rows) return codexFromRows(rows, cutoffMs)
|
|
328
|
+
}
|
|
329
|
+
return scanCodexRollouts(cutoffMs) // 老版 codex / 无 state db 时兜底
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function codexFromRows(rows, cutoffMs) {
|
|
333
|
+
const out = []
|
|
334
|
+
for (const r of rows) {
|
|
335
|
+
const updatedMs = toMs(r.updated_at)
|
|
336
|
+
if (updatedMs < cutoffMs) continue
|
|
337
|
+
const { turns, first } = countCodexTurns(r.rollout_path || '')
|
|
338
|
+
// codex 这几列全是用户原话(name 疑似手动命名字段,本机数据全空),硬截
|
|
339
|
+
const label = clipW(r.name || r.title || r.preview || r.first_user_message || first, 56)
|
|
340
|
+
const cand = {
|
|
341
|
+
tool: 'codex',
|
|
342
|
+
toolSessionId: r.id,
|
|
343
|
+
cwd: r.cwd || '',
|
|
344
|
+
label,
|
|
345
|
+
turns,
|
|
346
|
+
createdMs: toMs(r.created_at) || updatedMs,
|
|
347
|
+
updatedMs,
|
|
348
|
+
skip: '',
|
|
349
|
+
result: '',
|
|
350
|
+
path: r.rollout_path || '',
|
|
351
|
+
}
|
|
352
|
+
if (r.archived) cand.skip = '已归档'
|
|
353
|
+
else if (!label) cand.skip = '空会话'
|
|
354
|
+
out.push(cand)
|
|
355
|
+
}
|
|
356
|
+
return out
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function scanCodexRollouts(cutoffMs) {
|
|
360
|
+
const out = []
|
|
361
|
+
for (const file of listFiles(path.join(CODEX_DIR, 'sessions'))) {
|
|
362
|
+
if (!file.endsWith('.jsonl')) continue
|
|
363
|
+
let st
|
|
364
|
+
try {
|
|
365
|
+
st = fs.statSync(file)
|
|
366
|
+
} catch {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if (st.mtimeMs < cutoffMs) continue
|
|
370
|
+
let meta = null
|
|
371
|
+
for (const rec of jsonLines(file)) {
|
|
372
|
+
if (rec.type === 'session_meta') {
|
|
373
|
+
meta = rec.payload || {}
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const tid = meta && (meta.session_id || meta.id)
|
|
378
|
+
if (!tid) continue
|
|
379
|
+
const { turns, first } = countCodexTurns(file)
|
|
380
|
+
const cand = {
|
|
381
|
+
tool: 'codex',
|
|
382
|
+
toolSessionId: tid,
|
|
383
|
+
cwd: meta.cwd || '',
|
|
384
|
+
label: clipW(first, 56),
|
|
385
|
+
turns,
|
|
386
|
+
createdMs: st.mtimeMs,
|
|
387
|
+
updatedMs: st.mtimeMs,
|
|
388
|
+
skip: '',
|
|
389
|
+
result: '',
|
|
390
|
+
path: file,
|
|
391
|
+
}
|
|
392
|
+
if (!cand.cwd) cand.skip = '无 cwd'
|
|
393
|
+
out.push(cand)
|
|
394
|
+
}
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------- 过滤 / 关联
|
|
399
|
+
|
|
400
|
+
async function existingToolSessionIds() {
|
|
401
|
+
const res = await rpc('session:list', { limit: 100000 })
|
|
402
|
+
return new Set((res.sessions || []).map((s) => s.toolSessionId).filter(Boolean))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function applyFilters(cands, args, linked) {
|
|
406
|
+
const personaRoot = path.join(CLAWD_DIR, 'personas')
|
|
407
|
+
for (const c of cands) {
|
|
408
|
+
if (c.skip) continue
|
|
409
|
+
if (linked.has(c.toolSessionId)) c.skip = '已关联'
|
|
410
|
+
else if (c.turns < args.minTurns) c.skip = `少于 ${args.minTurns} 轮`
|
|
411
|
+
else if (!c.cwd || !isDir(c.cwd)) c.skip = '工作目录已不存在'
|
|
412
|
+
else if (
|
|
413
|
+
!args.includeClawd &&
|
|
414
|
+
(c.cwd === CLAWD_DIR || c.cwd === personaRoot ||
|
|
415
|
+
c.cwd.startsWith(personaRoot + path.sep) || c.cwd.startsWith(CLAWD_DIR + path.sep))
|
|
416
|
+
) {
|
|
417
|
+
c.skip = 'clawd 自己的会话'
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isDir(p) {
|
|
423
|
+
try {
|
|
424
|
+
return fs.statSync(p).isDirectory()
|
|
425
|
+
} catch {
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// SessionFile 只落这两处:<clawd>/sessions/** 和 <clawd>/personas/<id>/.clawd/sessions/**。
|
|
431
|
+
// 别退化成扫整个 <clawd>/personas——那底下有用户的 projects/ 和 node_modules,而本函数
|
|
432
|
+
// 每关联一条会话调一次,--limit 50 就是 50 次全树遍历。
|
|
433
|
+
function sessionRoots() {
|
|
434
|
+
const roots = [path.join(CLAWD_DIR, 'sessions')]
|
|
435
|
+
let personas = []
|
|
436
|
+
try {
|
|
437
|
+
personas = fs.readdirSync(path.join(CLAWD_DIR, 'personas'), { withFileTypes: true })
|
|
438
|
+
} catch {
|
|
439
|
+
/* 没有 personas 目录 */
|
|
440
|
+
}
|
|
441
|
+
for (const d of personas) {
|
|
442
|
+
if (d.isDirectory()) roots.push(path.join(CLAWD_DIR, 'personas', d.name, '.clawd', 'sessions'))
|
|
443
|
+
}
|
|
444
|
+
return roots
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function findSessionFile(sessionId) {
|
|
448
|
+
const want = `${sessionId}.json`
|
|
449
|
+
for (const root of sessionRoots()) {
|
|
450
|
+
const hit = listFiles(root).find((f) => path.basename(f) === want)
|
|
451
|
+
if (hit) return hit
|
|
452
|
+
}
|
|
453
|
+
return null
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 把 SessionFile 的时间戳改回原始会话的,sidebar 才按真实先后排
|
|
457
|
+
function backfillTimestamps(sessionId, cand) {
|
|
458
|
+
const file = findSessionFile(sessionId)
|
|
459
|
+
if (!file) return '会话文件没找到,时间戳未回填'
|
|
460
|
+
try {
|
|
461
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
462
|
+
data.createdAt = isoOf(cand.createdMs)
|
|
463
|
+
data.updatedAt = isoOf(cand.updatedMs)
|
|
464
|
+
const tmp = `${file}.tmp-import`
|
|
465
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
466
|
+
fs.renameSync(tmp, file)
|
|
467
|
+
return ''
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return `时间戳回填失败:${err.message}`
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function link(cand, icon) {
|
|
474
|
+
const created = await rpc('session:create', {
|
|
475
|
+
cwd: cand.cwd,
|
|
476
|
+
tool: cand.tool,
|
|
477
|
+
label: cand.label || `${cand.tool} 会话`,
|
|
478
|
+
iconKey: icon,
|
|
479
|
+
})
|
|
480
|
+
const sessionId = created.sessionId
|
|
481
|
+
if (!sessionId) throw new RpcError(`session:create 没返回 sessionId:${clip(JSON.stringify(created), 150)}`)
|
|
482
|
+
await rpc('session:resume', { sessionId, toolSessionId: cand.toolSessionId })
|
|
483
|
+
const warn = backfillTimestamps(sessionId, cand)
|
|
484
|
+
cand.result = sessionId + (warn ? `(${warn})` : '')
|
|
485
|
+
return sessionId
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------- 台账 / 撤销
|
|
489
|
+
|
|
490
|
+
function ledgerRead() {
|
|
491
|
+
try {
|
|
492
|
+
const data = JSON.parse(fs.readFileSync(LEDGER, 'utf8'))
|
|
493
|
+
return Array.isArray(data) ? data : []
|
|
494
|
+
} catch {
|
|
495
|
+
return []
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function ledgerWrite(entries) {
|
|
500
|
+
try {
|
|
501
|
+
fs.writeFileSync(LEDGER, JSON.stringify(entries, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
502
|
+
} catch (err) {
|
|
503
|
+
console.error(`(提醒:撤销台账写失败,--undo 将不可用:${err.message})`)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function doUndo(applyIt) {
|
|
508
|
+
const entries = ledgerRead()
|
|
509
|
+
if (!entries.length) {
|
|
510
|
+
console.log('台账是空的——本工具没有关联过任何会话。')
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const res = await rpc('session:list', { limit: 100000 })
|
|
514
|
+
const alive = new Set((res.sessions || []).map((s) => s.sessionId))
|
|
515
|
+
const todo = entries.filter((e) => alive.has(e.sessionId))
|
|
516
|
+
console.log(`台账 ${entries.length} 条,其中 ${todo.length} 条还在。` +
|
|
517
|
+
(applyIt ? '正在删除……' : '(预演;加 --apply 才真删)'))
|
|
518
|
+
for (const e of todo) console.log(` ${e.sessionId} ${e.label || ''}`)
|
|
519
|
+
if (!applyIt) return
|
|
520
|
+
let removed = 0
|
|
521
|
+
const failed = new Set()
|
|
522
|
+
for (const e of todo) {
|
|
523
|
+
try {
|
|
524
|
+
await rpc('session:delete', { sessionId: e.sessionId })
|
|
525
|
+
removed += 1
|
|
526
|
+
} catch (err) {
|
|
527
|
+
failed.add(e.sessionId)
|
|
528
|
+
console.error(` 删 ${e.sessionId} 失败:${err.message}`)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const doneIds = new Set(todo.map((t) => t.sessionId).filter((id) => !failed.has(id)))
|
|
532
|
+
ledgerWrite(entries.filter((e) => !doneIds.has(e.sessionId)))
|
|
533
|
+
console.log(`已删除 ${removed} 条。原 Claude Code / Codex 的会话文件没有任何改动。`)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------------------------------------------------------------- 参数
|
|
537
|
+
|
|
538
|
+
// clawd UI 认得的图标 key(源:ui/src/lib/session-icons.ts)。协议侧 iconKey 是自由字符串,
|
|
539
|
+
// daemon 不拒绝拼错的值,UI 查不到就不画图标——零提示,所以在参数层拦。
|
|
540
|
+
const ICON_KEYS = ['research', 'code', 'loop', 'qa', 'reading', 'debug', 'idea', 'doc', 'assist']
|
|
541
|
+
|
|
542
|
+
function parseArgs(argv) {
|
|
543
|
+
const a = {
|
|
544
|
+
days: 7, tool: 'both', apply: false, limit: 0, minTurns: 2,
|
|
545
|
+
includeClawd: false, icon: 'reading', undo: false, json: false,
|
|
546
|
+
}
|
|
547
|
+
for (let i = 0; i < argv.length; i++) {
|
|
548
|
+
const k = argv[i]
|
|
549
|
+
const next = () => {
|
|
550
|
+
const v = argv[++i]
|
|
551
|
+
if (v === undefined) die(`${k} 后面缺参数值`)
|
|
552
|
+
return v
|
|
553
|
+
}
|
|
554
|
+
switch (k) {
|
|
555
|
+
case '--days': a.days = Number(next()); break
|
|
556
|
+
case '--tool': a.tool = next(); break
|
|
557
|
+
case '--limit': a.limit = Number(next()); break
|
|
558
|
+
case '--min-turns': a.minTurns = Number(next()); break
|
|
559
|
+
case '--icon': a.icon = next(); break
|
|
560
|
+
case '--apply': a.apply = true; break
|
|
561
|
+
case '--include-clawd': a.includeClawd = true; break
|
|
562
|
+
case '--undo': a.undo = true; break
|
|
563
|
+
case '--json': a.json = true; break
|
|
564
|
+
case '-h': case '--help': a.help = true; break
|
|
565
|
+
default: die(`不认识的参数:${k}(--help 看用法)`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!['claude', 'codex', 'both'].includes(a.tool)) die(`--tool 只能是 claude / codex / both`)
|
|
569
|
+
// 数值参数显式校验:--min-turns abc → NaN,`turns < NaN` 恒 false,过滤会静默失灵
|
|
570
|
+
if (!Number.isFinite(a.days) || a.days <= 0) die('--days 得是正数')
|
|
571
|
+
if (!Number.isFinite(a.limit) || a.limit < 0) die('--limit 得是非负整数(0 = 不限)')
|
|
572
|
+
if (!Number.isFinite(a.minTurns) || a.minTurns < 0) die('--min-turns 得是非负整数')
|
|
573
|
+
if (!ICON_KEYS.includes(a.icon)) die(`--icon 只能是 ${ICON_KEYS.join(' / ')}`)
|
|
574
|
+
return a
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const HELP = `把本机 Claude Code / Codex 历史会话关联进 clawd
|
|
578
|
+
|
|
579
|
+
node link-sessions.mjs [选项]
|
|
580
|
+
|
|
581
|
+
--days N 回看多少天(默认 7)
|
|
582
|
+
--tool T claude / codex / both(默认 both)
|
|
583
|
+
--apply 真正写入;缺省只是 dry-run
|
|
584
|
+
--limit N 最多关联几条(0=不限),按最近优先
|
|
585
|
+
--min-turns N 少于这么多轮用户发言的会话跳过(默认 2)
|
|
586
|
+
--include-clawd 连 ~/.clawd 目录下的会话也关联(默认跳过)
|
|
587
|
+
--icon KEY research/code/loop/qa/reading/debug/idea/doc/assist(默认 reading)
|
|
588
|
+
--undo 撤销本工具关联过的会话(配 --apply 才真删)
|
|
589
|
+
--json 输出 JSON(含被跳过的和原因)`
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------- main
|
|
592
|
+
|
|
593
|
+
async function main() {
|
|
594
|
+
const args = parseArgs(process.argv.slice(2))
|
|
595
|
+
if (args.help) {
|
|
596
|
+
console.log(HELP)
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
if (args.undo) return doUndo(args.apply)
|
|
600
|
+
|
|
601
|
+
const cutoffMs = Date.now() - args.days * 86400_000
|
|
602
|
+
let cands = []
|
|
603
|
+
if (args.tool === 'claude' || args.tool === 'both') cands = cands.concat(scanClaude(cutoffMs))
|
|
604
|
+
if (args.tool === 'codex' || args.tool === 'both') cands = cands.concat(scanCodex(cutoffMs))
|
|
605
|
+
|
|
606
|
+
applyFilters(cands, args, await existingToolSessionIds())
|
|
607
|
+
cands.sort((a, b) => b.updatedMs - a.updatedMs)
|
|
608
|
+
|
|
609
|
+
let todo = cands.filter((c) => !c.skip)
|
|
610
|
+
if (args.limit && todo.length > args.limit) {
|
|
611
|
+
for (const c of todo.slice(args.limit)) c.skip = `超出 --limit ${args.limit}`
|
|
612
|
+
todo = todo.slice(0, args.limit)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (args.apply) {
|
|
616
|
+
for (const c of todo) {
|
|
617
|
+
try {
|
|
618
|
+
const sid = await link(c, args.icon)
|
|
619
|
+
// 每成功一条就落台账:攒到循环结束再写的话,中途崩了 --undo 就清不掉已建的会话
|
|
620
|
+
ledgerWrite(
|
|
621
|
+
ledgerRead().concat([
|
|
622
|
+
{ sessionId: sid, tool: c.tool, label: c.label, toolSessionId: c.toolSessionId },
|
|
623
|
+
]),
|
|
624
|
+
)
|
|
625
|
+
} catch (err) {
|
|
626
|
+
c.result = `失败:${err.message}`
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (args.json) {
|
|
632
|
+
console.log(JSON.stringify(cands, null, 2))
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
console.log(`回看 ${args.days} 天:候选 ${cands.length} 条,可关联 ${todo.length} 条` +
|
|
637
|
+
(args.apply ? ' (已写入)' : ' (预演,加 --apply 才写入)') + '\n')
|
|
638
|
+
if (todo.length) {
|
|
639
|
+
console.log(`${pad('工具', 8)}${pad('最后活跃', 14)}${pad('轮', 4)}${pad('标题', 36)}工作目录`)
|
|
640
|
+
console.log('-'.repeat(110))
|
|
641
|
+
}
|
|
642
|
+
for (const c of todo) {
|
|
643
|
+
const d = new Date(c.updatedMs)
|
|
644
|
+
const p2 = (n) => String(n).padStart(2, '0')
|
|
645
|
+
const when = `${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`
|
|
646
|
+
const cwd = c.cwd.replace(HOME, '~')
|
|
647
|
+
const tail = c.result ? ` → ${c.result}` : ''
|
|
648
|
+
console.log(pad(c.tool, 8) + pad(when, 14) + pad(String(c.turns), 4) +
|
|
649
|
+
pad(clipW(c.label, 34), 36) + clipW(cwd, 40) + tail)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const skipped = cands.filter((c) => c.skip)
|
|
653
|
+
if (skipped.length) {
|
|
654
|
+
const reasons = new Map()
|
|
655
|
+
for (const c of skipped) reasons.set(c.skip, (reasons.get(c.skip) || 0) + 1)
|
|
656
|
+
const detail = [...reasons].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(',')
|
|
657
|
+
console.log(`\n跳过 ${skipped.length} 条:${detail}`)
|
|
658
|
+
}
|
|
659
|
+
if (args.apply && todo.length) {
|
|
660
|
+
console.log('\n原 Claude Code / Codex 的会话文件未被改动,只是在 clawd 里建了引用。' +
|
|
661
|
+
'\n撤销:node link-sessions.mjs --undo --apply')
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
main().catch((err) => die(err instanceof RpcError ? err.message : (err.stack || String(err))))
|
|
@@ -16,7 +16,7 @@ call({ method: "persona:list" }) # clawd-rpc MCP tool
|
|
|
16
16
|
|
|
17
17
|
**不猜**:拿不准就查(Read / Bash / WebFetch),不要凭印象编 clawd 功能。
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## 6 个 skill
|
|
20
20
|
|
|
21
21
|
按老板意图触发对应 skill;skill 自己 fetch 线上文档回答:
|
|
22
22
|
|
|
@@ -27,6 +27,7 @@ call({ method: "persona:list" }) # clawd-rpc MCP tool
|
|
|
27
27
|
| session 汇总 / 搜话题 / 拉全文 | `clawd-session-lens` |
|
|
28
28
|
| dispatch / DM / 排定时 / 管联系人 | `clawd-orchestration` |
|
|
29
29
|
| 改 persona.json / CLAUDE.md / sandbox / profile / contacts | `clawd-config-editor` |
|
|
30
|
+
| 把 Claude Code / Codex 的历史会话关联进 clawd(冷启动) | `clawd-session-import` |
|
|
30
31
|
|
|
31
32
|
## Setup(新会话第一轮 check 一次)
|
|
32
33
|
|