/** * v6.1.0 trajectory 自动归档模块(v2 路径策略) * * 历史:v5.7.20 引入;mv 到 `sessions/.archive/`。 * * 5/2 实战发现 v1 路径策略**根本不解决慢**——OpenClaw 的 sessions.list 是 * **递归扫整个 sessions/ 子树**(含 .archive 子目录),所以 mv 到子目录里 * gateway 主线程仍 JSON.parse 这些文件。30 秒采样 21478/21645 hits(**99.2%**) * 卡在 V8 ParseJsonObject + array.map(JSON.parse) 模式 = sessions.list 全量解析。 * * v2 路径策略:把归档目录从 sessions/.archive 提升到 sessions-archive * (**跟 sessions/ 平级**,不在子树)→ openclaw 看不见 → 主线程不扫。 * 实测:main agent sessions/ 子树体量 68MB → 29MB;keepermac TTFB 突刺 * 49/34/31/776/3449ms → 全部 30-33ms 稳定无突刺。 * * 红线: * - 不修 openclaw 核心(红线 #1) * - 不复制龙虾原生(archiver 是 enhance 范围) * - 不用 child_process(红线 #4)—— LaunchAgent 由 launchd 跑 bash * - 启动期纯 fs.existsSync,不写不改用户配置 * - 不归档活跃 session(mtime > ageDays 才动;.jsonl 对话原文一律保留) */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; const DEFAULT_LABEL = "ai.huo15.openclaw.trajectory-archiver"; // v2 标记:旧版 plist 含 inline `find ... mv ... sessions/.archive` 字面量; // 启动期检测到 → 提示用户重新跑 setup 拿新版(v2 路径策略) const V1_PATH_MARKER = "sessions/.archive"; interface TrajectoryArchiverConfig { enabled?: boolean; /** LaunchAgent label,撞库时可改 */ label?: string; /** mtime 阈值(天),早于此天数才归档;默认 1 */ archiveAgeDays?: number; /** size 阈值(MB),≥ 才归档;默认 2 */ archiveMinSizeMB?: number; /** 触发时刻 hour(0-23);默认 3(凌晨 3 点) */ scheduleHour?: number; /** 是否归档 *.checkpoint.*.jsonl(v2 新增,默认 true) */ archiveCheckpoints?: boolean; } function plistPath(label: string): string { return join(homedir(), "Library", "LaunchAgents", `${label}.plist`); } function scriptPath(): string { return join(homedir(), ".openclaw", "scripts", "trajectory-archiver.sh"); } function logPath(): string { return join(homedir(), ".openclaw", "logs", "trajectory-archiver.log"); } /** v2 归档脚本(每个 agent 独立 sessions-archive,移出 sessions/ 子树) */ function buildArchiveScript(opts: { ageDays: number; minSizeMB: number; archiveCheckpoints: boolean; }): string { const ageMin = opts.ageDays * 1440; const minSizeKb = Math.max(1, Math.round(opts.minSizeMB * 1024)); const namePatterns = opts.archiveCheckpoints ? `\\( -name '*.trajectory.jsonl' -o -name '*.checkpoint.*.jsonl' \\)` : `-name '*.trajectory.jsonl'`; return `#!/usr/bin/env bash # enhance trajectory-archiver v2 — auto-generated by enhance_trajectory_archiver_setup # 归档策略: # 1. 每个 agent 的 sessions/ 顶层 stale trajectory + checkpoint → mv 到同级 sessions-archive/ # 2. sessions-archive/ 里 >7 天 → delete # 3. *.jsonl.reset.* / *.jsonl.bak-* >3 天 → delete # 关键:sessions-archive 跟 sessions/ 平级而非子目录,sessions.list 不会扫。 set -e ts() { date '+%F %T'; } count_files_under_sessions() { find "$HOME/.openclaw/agents" -path '*/sessions/*' \\ \\( -name '*.jsonl' -o -name '*.trajectory.jsonl' -o -name '*.checkpoint.*.jsonl' \\) \\ 2>/dev/null | wc -l | tr -d ' ' } count_mb_under_sessions() { find "$HOME/.openclaw/agents" -path '*/sessions/*' \\ \\( -name '*.jsonl' -o -name '*.trajectory.jsonl' -o -name '*.checkpoint.*.jsonl' \\) \\ 2>/dev/null -exec ls -l {} + | awk '{s+=$5} END {printf "%.0f", s/1024/1024}' } echo "[$(ts)] enhance trajectory-archiver v2 start" BEFORE_N=$(count_files_under_sessions) BEFORE_MB=$(count_mb_under_sessions) echo " before (under sessions/): \${BEFORE_N} files / \${BEFORE_MB} MB" echo " [1/3] mv stale trajectory + checkpoint to sessions-archive (per agent)" for AGENT_DIR in "$HOME"/.openclaw/agents/*/; do AGENT=$(basename "$AGENT_DIR") SESSIONS="$AGENT_DIR/sessions" ARCHIVE="$AGENT_DIR/sessions-archive" [ -d "$SESSIONS" ] || continue mkdir -p "$ARCHIVE" MOVED=$(find "$SESSIONS" -maxdepth 1 \\ ${namePatterns} \\ -mmin +${ageMin} -size +${minSizeKb}k -print 2>/dev/null | wc -l | tr -d ' ') if [ "$MOVED" -gt 0 ]; then find "$SESSIONS" -maxdepth 1 \\ ${namePatterns} \\ -mmin +${ageMin} -size +${minSizeKb}k -exec mv {} "$ARCHIVE/" \\; echo " \${AGENT}: moved \${MOVED} files to sessions-archive/" fi # 顺手清理 v1 残留:旧版 mv 到 sessions/.archive 的文件,迁移到 sessions-archive if [ -d "$SESSIONS/.archive" ]; then LEGACY=$(find "$SESSIONS/.archive" -name '*.jsonl' 2>/dev/null | wc -l | tr -d ' ') if [ "$LEGACY" -gt 0 ]; then mv "$SESSIONS/.archive"/*.jsonl "$ARCHIVE/" 2>/dev/null || true echo " \${AGENT}: migrated \${LEGACY} v1 .archive files to sessions-archive/" fi fi done echo " [2/3] delete sessions-archive items >7 days" find "$HOME/.openclaw/agents" -path '*/sessions-archive/*' -mtime +7 -delete 2>/dev/null || true echo " [3/3] delete *.jsonl.reset.* / *.jsonl.bak-* >3 days" find "$HOME/.openclaw/agents" -maxdepth 4 \\( -name '*.jsonl.reset.*' -o -name '*.jsonl.bak-*' \\) -mtime +3 -delete AFTER_N=$(count_files_under_sessions) AFTER_MB=$(count_mb_under_sessions) echo " after (under sessions/): \${AFTER_N} files / \${AFTER_MB} MB (delta -\$((BEFORE_N-AFTER_N)) files / -\$((BEFORE_MB-AFTER_MB)) MB)" echo "[$(ts)] enhance trajectory-archiver v2 done" `; } function buildPlistContent(opts: { label: string; hour: number }): string { return ` Label${opts.label} ProgramArguments /bin/bash ${scriptPath()} StartCalendarInterval Hour${opts.hour} Minute0 StandardOutPath${logPath()} StandardErrorPath${logPath()} RunAtLoad `; } function buildSetupCliCmd(opts: { label: string; ageDays: number; minSizeMB: number; hour: number; archiveCheckpoints: boolean; }): string { const path = plistPath(opts.label); const script = scriptPath(); const scriptContent = buildArchiveScript({ ageDays: opts.ageDays, minSizeMB: opts.minSizeMB, archiveCheckpoints: opts.archiveCheckpoints, }); const plistContent = buildPlistContent({ label: opts.label, hour: opts.hour }); return [ `# enhance trajectory-archiver v2 一次性部署 (写脚本 + plist + 加载)`, `# 路径策略修正:归档目录从 sessions/.archive 提升到 sessions-archive (移出 sessions/ 子树)`, `# 这样 openclaw sessions.list 不会扫到归档文件,主线程 JSON.parse 工作量大幅下降`, ``, `mkdir -p "${join(homedir(), ".openclaw", "scripts")}"`, `cat > "${script}" << 'SCRIPT_EOF'`, scriptContent, `SCRIPT_EOF`, `chmod +x "${script}"`, ``, `mkdir -p "${join(homedir(), "Library", "LaunchAgents")}"`, `cat > "${path}" << 'PLIST_EOF'`, plistContent, `PLIST_EOF`, ``, `# 加载 (若旧版已加载,先 unload 防 label 冲突)`, `launchctl unload "${path}" 2>/dev/null || true`, `launchctl load "${path}"`, ``, `# 立即跑一次 (验证脚本)`, `launchctl start ${opts.label} && sleep 2 && tail -20 "${logPath()}"`, ``, `# 后续验证: launchctl list | grep ${opts.label}`, `# 卸载: launchctl unload "${path}" && rm "${path}" "${script}"`, ].join("\n"); } /** 检测旧版 plist (v1 路径策略) */ function detectV1Plist(label: string): boolean { const path = plistPath(label); if (!existsSync(path)) return false; try { const content = readFileSync(path, "utf8"); return content.includes(V1_PATH_MARKER); } catch { return false; } } export function registerTrajectoryArchiver( api: OpenClawPluginApi, config: TrajectoryArchiverConfig | undefined, ) { const label = config?.label ?? DEFAULT_LABEL; const ageDays = config?.archiveAgeDays ?? 1; const minSizeMB = config?.archiveMinSizeMB ?? 2; const hour = config?.scheduleHour ?? 3; const archiveCheckpoints = config?.archiveCheckpoints ?? true; // 启动期:检测旧版 plist (v1 路径) → 强烈建议重新部署 try { const path = plistPath(label); if (existsSync(path)) { if (detectV1Plist(label)) { api.logger.warn( `[enhance-trajectory-archiver] ⚠️ 检测到旧版 v1 LaunchAgent (含 'sessions/.archive' 路径)。` + `v1 策略不解决卡顿—— sessions.list 仍会扫归档文件。请运行 ` + `enhance_trajectory_archiver_setup 工具拿 v2 部署命令重新部署。`, ); } else { api.logger.info( `[enhance-trajectory-archiver] LaunchAgent v2 已部署: ${path}(每日 ${hour .toString() .padStart(2, "0")}:00 自动归档,目标 sessions-archive/ 目录)`, ); } } else { api.logger.info( `[enhance-trajectory-archiver] 未部署自动归档(运行 enhance_trajectory_archiver_setup 拿 v2 部署命令)`, ); } } catch (err) { api.logger.error( `[enhance-trajectory-archiver] 启动检查失败(不影响插件其它功能): ${(err as Error).message}`, ); } api.registerTool( ((_ctx: OpenClawPluginToolContext) => ({ name: "enhance_trajectory_archiver_setup", description: "输出 macOS launchd LaunchAgent 部署命令(v2 路径策略):把归档目录从 sessions/.archive 提升到 " + "sessions-archive(移出 sessions/ 子树,openclaw sessions.list 不再扫到归档文件)。" + "v6.1.0 修复 v1 路径策略不解决卡顿的根因——5/2 实战中 30s 采样 99.2% 主线程卡 V8 JsonParser。" + "只输出命令,不擅自执行(你复制粘贴到 shell 跑一次即可)。", parameters: Type.Object({}), async execute() { const path = plistPath(label); const alreadyDeployed = existsSync(path); const isV1 = detectV1Plist(label); const cliCmd = buildSetupCliCmd({ label, ageDays, minSizeMB, hour, archiveCheckpoints, }); const lines: string[] = []; if (alreadyDeployed && isV1) { lines.push(`⚠️ 检测到 v1 LaunchAgent(路径 sessions/.archive)`); lines.push(`v1 策略不解决卡顿——openclaw sessions.list 仍会扫归档文件。`); lines.push(`请用以下命令重新部署到 v2(自动 unload 旧的):`); lines.push(``); } else if (alreadyDeployed) { lines.push(`✅ LaunchAgent v2 已部署:${path}`); lines.push(`触发条件:每日 ${hour.toString().padStart(2, "0")}:00`); lines.push(` - 文件 ≥ ${minSizeMB}MB`); lines.push(` - mtime > ${ageDays} 天`); lines.push(` - 归档 .trajectory.jsonl${archiveCheckpoints ? " + .checkpoint.*.jsonl" : ""}`); lines.push(` - 目标目录:每个 agent 的 sessions-archive/(移出 sessions/ 子树)`); lines.push(``); lines.push(`查看日志: tail ${logPath()}`); lines.push(`手动触发: launchctl start ${label}`); lines.push(`卸载: launchctl unload "${path}" && rm "${path}"`); lines.push(``); lines.push(`如需更新(例如改阈值)请用以下命令重新部署:`); lines.push(``); } else { lines.push(`一次性部署 macOS LaunchAgent v2(每日 ${hour.toString().padStart(2, "0")}:00 自动归档):`); lines.push(``); } lines.push("```bash"); lines.push(cliCmd); lines.push("```"); if (!alreadyDeployed) { lines.push(``); lines.push(`触发条件:`); lines.push(` - 文件 ≥ ${minSizeMB}MB`); lines.push(` - mtime > ${ageDays} 天(活动 session 受保护)`); lines.push(` - 归档 .trajectory.jsonl${archiveCheckpoints ? " + .checkpoint.*.jsonl" : ""}`); lines.push(` - 目标目录:每个 agent 的 sessions-archive/(**跟 sessions/ 平级**,不在子树)`); lines.push(``); lines.push( `部署后 openclaw sessions.list 不会扫到归档文件,主线程 JSON.parse 工作量大幅下降。` + `配合 enhance_session_doctor 监控 trajectory 总量。`, ); } return { content: [{ type: "text" as const, text: lines.join("\n") }] }; }, })) as any, { name: "enhance_trajectory_archiver_setup" }, ); api.logger.info( "[enhance] trajectory 自动归档模块 v2 已加载(路径策略:sessions-archive 移出 sessions/ 子树;只读 + return-cliCmd)", ); }