@clawos-dev/clawd 0.2.274 → 0.2.275
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 +24 -7
- 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/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -48016,9 +48016,11 @@ var DEFAULT_PERSONAS = [
|
|
|
48016
48016
|
{
|
|
48017
48017
|
// clawd 管家 (chief-of-staff):spec 2026-07-22-clawd-butler-persona
|
|
48018
48018
|
// 新 personaId 独立于老 persona-clawd-helper(老 helper seed entry 已删;老用户机器上
|
|
48019
|
-
// 若有残留目录,是他们自留物,daemon 不再触碰)。bundle 里 CLAUDE.md 骨架 +
|
|
48020
|
-
// prebuild skill
|
|
48021
|
-
//
|
|
48019
|
+
// 若有残留目录,是他们自留物,daemon 不再触碰)。bundle 里 CLAUDE.md 骨架 + 6 个
|
|
48020
|
+
// prebuild skill:新装用户走 copyBundleExtras,存量用户走 refreshDaemonManagedDirs
|
|
48021
|
+
// (`.claude/skills` 在 DAEMON_MANAGED_PATHS 里),都装到 `.claude/skills/`。
|
|
48022
|
+
// 前 5 个 skill fetch 用 daemon 注入的 env CLAWOS_API 拼 `${CLAWOS_API}/api/docs/**`
|
|
48023
|
+
// 拉线上手册;clawd-session-import 自带 scripts/link-sessions.mjs,只依赖 node 内置模块。
|
|
48022
48024
|
personaId: "persona-clawd-butler",
|
|
48023
48025
|
label: "clawd \u7BA1\u5BB6",
|
|
48024
48026
|
model: "opus",
|
|
@@ -48257,7 +48259,22 @@ function copyBundleExtras(srcDir, dstDir) {
|
|
|
48257
48259
|
}
|
|
48258
48260
|
}
|
|
48259
48261
|
}
|
|
48260
|
-
var DAEMON_MANAGED_PATHS = ["extension-kit", "CLAUDE.md", ".mcp.json"];
|
|
48262
|
+
var DAEMON_MANAGED_PATHS = ["extension-kit", "CLAUDE.md", ".mcp.json", ".claude/skills"];
|
|
48263
|
+
function replaceManagedPath(src, dst) {
|
|
48264
|
+
if (!fs12.statSync(src).isDirectory()) {
|
|
48265
|
+
fs12.mkdirSync(path14.dirname(dst), { recursive: true });
|
|
48266
|
+
fs12.rmSync(dst, { recursive: true, force: true });
|
|
48267
|
+
fs12.cpSync(src, dst, { dereference: true });
|
|
48268
|
+
return;
|
|
48269
|
+
}
|
|
48270
|
+
fs12.mkdirSync(dst, { recursive: true });
|
|
48271
|
+
for (const e of fs12.readdirSync(src, { withFileTypes: true })) {
|
|
48272
|
+
if (e.name === "node_modules") continue;
|
|
48273
|
+
const child = path14.join(dst, e.name);
|
|
48274
|
+
fs12.rmSync(child, { recursive: true, force: true });
|
|
48275
|
+
fs12.cpSync(path14.join(src, e.name), child, { recursive: true, dereference: true });
|
|
48276
|
+
}
|
|
48277
|
+
}
|
|
48261
48278
|
function refreshDaemonManagedDirs(args) {
|
|
48262
48279
|
const entries = args.entries ?? DEFAULT_PERSONAS;
|
|
48263
48280
|
for (const entry of entries) {
|
|
@@ -48270,9 +48287,9 @@ function refreshDaemonManagedDirs(args) {
|
|
|
48270
48287
|
if (!fs12.existsSync(srcPath)) continue;
|
|
48271
48288
|
const dstPath = path14.join(personaDir, relPath);
|
|
48272
48289
|
try {
|
|
48273
|
-
|
|
48290
|
+
replaceManagedPath(srcPath, dstPath);
|
|
48274
48291
|
if (relPath === "CLAUDE.md") {
|
|
48275
|
-
|
|
48292
|
+
replaceManagedPath(srcPath, path14.join(personaDir, "AGENTS.md"));
|
|
48276
48293
|
}
|
|
48277
48294
|
args.logger.info("persona.refresh.synced", {
|
|
48278
48295
|
personaId: entry.personaId,
|
|
@@ -57914,7 +57931,7 @@ function computeMethodAccess(args) {
|
|
|
57914
57931
|
}
|
|
57915
57932
|
|
|
57916
57933
|
// src/version.ts
|
|
57917
|
-
var version = "0.2.
|
|
57934
|
+
var version = "0.2.275".length > 0 ? "0.2.275" : "dev";
|
|
57918
57935
|
|
|
57919
57936
|
// src/cli-probe/probe.ts
|
|
57920
57937
|
var fs54 = __toESM(require("fs"), 1);
|
|
@@ -85,11 +85,24 @@ PR="$HOME/.clawd/personas/persona-app-builder"; DK="$HOME/.clawd/deploy-kit"
|
|
|
85
85
|
## 换语言(Node → Python/Go…)
|
|
86
86
|
|
|
87
87
|
1. `config.env` 的 `NODE_LAYER` 换成对应语言的 FC 运行时层
|
|
88
|
-
2. 替换
|
|
88
|
+
2. 替换 bootstrap(如 `exec python app.py`)—— 先读下面的「⚠️ bootstrap 归 daemon 管」
|
|
89
89
|
3. `ext.conf` 的 `BUILD_CMD` 改成对应构建命令
|
|
90
90
|
|
|
91
91
|
`s.yaml.tmpl` 结构不变。
|
|
92
92
|
|
|
93
|
+
### ⚠️ bootstrap 归 daemon 管,改了会被还原
|
|
94
|
+
|
|
95
|
+
`publish.sh` 用的是**共享 deploy-kit** 那份:`$HOME/.clawd/deploy-kit/contract/bootstrap`
|
|
96
|
+
(不是本目录下的)。而 `deploy-kit/contract/` 和本 `extension-kit/` 一样,**每次 daemon 启动
|
|
97
|
+
都会被 bundle 版本无条件覆盖**——就地改它,下次重启就没了,而且没有 `.secrets/*.local`
|
|
98
|
+
那样的个人 override 豁免。
|
|
99
|
+
|
|
100
|
+
在本目录下新建 `contract/bootstrap` 也不行:`extension-kit/` 是 daemon-managed 路径,
|
|
101
|
+
`contract/` 是 bundle 里有的子目录,整个会被替换掉。
|
|
102
|
+
|
|
103
|
+
所以**换语言目前没有能存住的改法**,需要产品侧先给 bootstrap 一个 override 机制。
|
|
104
|
+
在那之前,非 Node 运行时请找老板确认怎么走,别就地改了以为生效。
|
|
105
|
+
|
|
93
106
|
## 红线(都是踩坑换来的,见记忆 fc-nodejs-deploy-recipe)
|
|
94
107
|
|
|
95
108
|
- **Supabase 是 clawos 共享生产库**:新表必须 `${APP_NAME}_${SLUG}_` 前缀(从 ext.conf 读),**绝不 drop/alter clawos 已有表**
|
package/dist/persona-defaults/persona-clawd-butler/.claude/skills/clawd-session-import/SKILL.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clawd-session-import
|
|
3
|
+
description: 把用户本机 Claude Code / Codex 的历史会话关联成 clawd 会话,让他们在 clawd 里直接看到并接着聊。当用户说「把我之前在 Claude Code / Codex 的对话弄进 clawd」「刚从别的工具切过来,历史记录能带过来吗」「clawd 里怎么是空的,我以前的会话呢」「导入 / 关联历史会话」,或刚开始用 clawd 需要冷启动时使用。不负责删除会话、不负责搬运文件。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 把 Claude Code / Codex 的历史会话接进 clawd
|
|
7
|
+
|
|
8
|
+
用户在 Claude Code 或 Codex 里已经聊过很多,刚切到 clawd 会看到一个空侧边栏,
|
|
9
|
+
不知道从哪儿接着干。这个 skill 扫描本机这两个工具最近的会话,在 clawd 里给每条
|
|
10
|
+
建一个引用,于是它们出现在侧边栏——点开能看到完整原始对话,发消息就接着往下聊。
|
|
11
|
+
|
|
12
|
+
**只建引用,不复制、不移动、不修改任何原始会话文件**,原会话在 Claude Code / Codex
|
|
13
|
+
里照常可用。关联是可撤销的。
|
|
14
|
+
|
|
15
|
+
## 用法
|
|
16
|
+
|
|
17
|
+
只依赖 node(clawd 本身就是 node 跑的,必然有)。脚本就在本 skill 目录下,下面的命令
|
|
18
|
+
按会话工作目录(persona 目录)写相对路径。
|
|
19
|
+
|
|
20
|
+
先预演,看看会关联哪些(不写任何东西):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node .claude/skills/clawd-session-import/scripts/link-sessions.mjs --days 7
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**把预演结果念给用户看**(几条、都是什么话题、来自哪些目录),确认后再执行:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node .claude/skills/clawd-session-import/scripts/link-sessions.mjs --days 7 --apply
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
常用参数:
|
|
33
|
+
|
|
34
|
+
| 参数 | 作用 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `--days N` | 回看多少天,默认 7 |
|
|
37
|
+
| `--tool claude\|codex\|both` | 只处理其中一边,默认两边都要 |
|
|
38
|
+
| `--limit N` | 最多关联 N 条(按最近优先)。会话多的用户建议先 `--limit 20`,别一次灌满侧边栏 |
|
|
39
|
+
| `--min-turns N` | 少于 N 轮用户发言的会话跳过,默认 2(滤掉「hello」这种试水会话) |
|
|
40
|
+
| `--include-clawd` | 连 `~/.clawd` 目录下的会话也关联。默认跳过——那些本来就是 clawd 自己开的 |
|
|
41
|
+
| `--icon KEY` | 侧边栏图标:`research/code/loop/qa/reading/debug/idea/doc/assist`,默认 `reading` |
|
|
42
|
+
| `--json` | 输出 JSON,含被跳过的条目和原因,便于你自己筛 |
|
|
43
|
+
|
|
44
|
+
撤销(删掉本工具建过的全部引用,原会话文件不受影响):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node .claude/skills/clawd-session-import/scripts/link-sessions.mjs --undo # 预演
|
|
48
|
+
node .claude/skills/clawd-session-import/scripts/link-sessions.mjs --undo --apply # 真删
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 原理(已对 clawd 源码核实)
|
|
52
|
+
|
|
53
|
+
一条 clawd 会话的元数据里有 `cwd` + `toolSessionId` 两个字段,指向底层工具那条真实
|
|
54
|
+
对话。关联就是把这两个字段填对:
|
|
55
|
+
|
|
56
|
+
1. `session:create` —— 只写一份元数据文件,**不拉起任何进程**,所以批量关联很便宜
|
|
57
|
+
2. `session:resume` —— 该会话还没进程时,只是把 `toolSessionId` 写进元数据
|
|
58
|
+
3. 脚本再把元数据里的创建/更新时间改回原会话的真实时间,侧边栏才按真实先后排序
|
|
59
|
+
(clawd 每次列会话都重读磁盘,改完立刻生效,不用重启)
|
|
60
|
+
|
|
61
|
+
用户点开时,clawd 按 `(cwd, toolSessionId)` 去读原始 transcript 渲染;发消息才真正
|
|
62
|
+
把 Claude Code / Codex 进程拉起来 resume 那条对话。
|
|
63
|
+
|
|
64
|
+
两边的会话数据在哪:
|
|
65
|
+
|
|
66
|
+
- Claude Code:`~/.claude/projects/<编码后的 cwd>/<会话 id>.jsonl`,文件名就是会话 id
|
|
67
|
+
- Codex:`~/.codex/state_*.sqlite` 的 `threads` 表(权威索引,含 cwd / 时间 / 标题 /
|
|
68
|
+
rollout 路径)。读表按 `node:sqlite` → `sqlite3` 命令行 → 解析
|
|
69
|
+
`~/.codex/sessions/**/rollout-*.jsonl` 三级回落,老 Node / 老 codex 都能work
|
|
70
|
+
|
|
71
|
+
## 标题(label)从哪来
|
|
72
|
+
|
|
73
|
+
**Claude Code 有现成的**:transcript 里 `type: "ai-title"` 那行的 `aiTitle`,是 CC 自己
|
|
74
|
+
生成的会话标题(如「扫描设备sessions创建用户persona」),直接拿来用。实测覆盖率 89%,
|
|
75
|
+
且缺失的那批全是 0~1 轮的「hello」「你是谁」,被默认 `--min-turns 2` 挡掉了——**实际关联
|
|
76
|
+
进来的 CC 会话都有现成标题**。
|
|
77
|
+
|
|
78
|
+
(注:clawd 原生的历史列表用的是另一套优先级 `last-prompt` → `summary` → 首句前 120 字,
|
|
79
|
+
没用 `ai-title`。所以这里的标题会比 clawd 历史列表更像标题,属预期。)
|
|
80
|
+
|
|
81
|
+
**Codex 没有**:`threads` 表的 `title` / `preview` / `first_user_message` 三列都是第一条
|
|
82
|
+
用户消息**原文**,不是 AI 摘要;`name` 疑似手动命名字段,实测数据全空。取值顺序
|
|
83
|
+
`name` → `title` → `preview` → `first_user_message` → 解析 rollout 取首句,将来 codex
|
|
84
|
+
补上真标题能自动吃到。
|
|
85
|
+
|
|
86
|
+
拿到什么就是什么,**按显示宽度硬截到 56 列**(中文算 2 列),不做任何清洗或改写——
|
|
87
|
+
标题应当忠实反映用户原话,不引入猜测。唯一的处理是把换行 / 连续空白折叠成单空格,
|
|
88
|
+
因为标题是单行的。所以开头是长 URL 的 codex 会话,标题就是那段 URL,这是预期行为;
|
|
89
|
+
用户在 clawd 里随时能手动改标题。
|
|
90
|
+
|
|
91
|
+
## 会被跳过的(脚本自动处理,不用你操心)
|
|
92
|
+
|
|
93
|
+
- **已关联**:该会话 id 已经出现在某条 clawd 会话里,不会重复建
|
|
94
|
+
- **clawd 自己的会话**:`cwd` 在 `~/.clawd` 底下的,本来就是 clawd 开的(`--include-clawd` 可覆盖)
|
|
95
|
+
- **subagent 支线**:Claude Code 的子 agent transcript,不是独立会话
|
|
96
|
+
- **工作目录已不存在**:原目录被删了,clawd 建不了会话
|
|
97
|
+
- **轮次太少**:低于 `--min-turns`
|
|
98
|
+
|
|
99
|
+
## 注意
|
|
100
|
+
|
|
101
|
+
- clawd 桌面端得在跑,否则脚本连不上(会明确报错)
|
|
102
|
+
- 关联完让用户刷新一下 clawd 界面才看得到
|
|
103
|
+
- Codex 会话在 clawd 里不支持回滚 / fork / 实时接管(这是 codex 侧的能力边界,
|
|
104
|
+
不是关联的问题);能看能续聊
|
|
105
|
+
- 会话很多时一次全灌会把侧边栏冲爆,建议配 `--limit` 分批,或先用 `--days 3` 试水
|
|
@@ -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
|
|
|
@@ -75,11 +75,24 @@ PR="$HOME/.clawd/personas/persona-dataclaw-builder"; DK="$HOME/.clawd/deploy-kit
|
|
|
75
75
|
## 换语言(Node → Python/Go…)
|
|
76
76
|
|
|
77
77
|
1. `config.env` 的 `NODE_LAYER` 换成对应语言的 FC 运行时层
|
|
78
|
-
2. 替换
|
|
78
|
+
2. 替换 bootstrap(如 `exec python app.py`)—— 先读下面的「⚠️ bootstrap 归 daemon 管」
|
|
79
79
|
3. `ext.conf` 的 `BUILD_CMD` 改成对应构建命令
|
|
80
80
|
|
|
81
81
|
`s.yaml.tmpl` 结构不变。
|
|
82
82
|
|
|
83
|
+
### ⚠️ bootstrap 归 daemon 管,改了会被还原
|
|
84
|
+
|
|
85
|
+
`publish.sh` 用的是**共享 deploy-kit** 那份:`$HOME/.clawd/deploy-kit/contract/bootstrap`
|
|
86
|
+
(不是本目录下的)。而 `deploy-kit/contract/` 和本 `extension-kit/` 一样,**每次 daemon 启动
|
|
87
|
+
都会被 bundle 版本无条件覆盖**——就地改它,下次重启就没了,而且没有 `.secrets/*.local`
|
|
88
|
+
那样的个人 override 豁免。
|
|
89
|
+
|
|
90
|
+
在本目录下新建 `contract/bootstrap` 也不行:`extension-kit/` 是 daemon-managed 路径,
|
|
91
|
+
`contract/` 是 bundle 里有的子目录,整个会被替换掉。
|
|
92
|
+
|
|
93
|
+
所以**换语言目前没有能存住的改法**,需要产品侧先给 bootstrap 一个 override 机制。
|
|
94
|
+
在那之前,非 Node 运行时请找老板确认怎么走,别就地改了以为生效。
|
|
95
|
+
|
|
83
96
|
## 红线(都是踩坑换来的,见记忆 fc-nodejs-deploy-recipe)
|
|
84
97
|
|
|
85
98
|
- **必须绑自定义域名**(fc3-domain auto),否则 `fcapp.run` 默认 URL 强制下载网页、浏览器不渲染
|