@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.
@@ -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
- ## 5 个 skill
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