diff --git a/docs/cn/ai/external-agent.md b/docs/cn/ai/external-agent.md index 62835163..10fc20b9 100644 --- a/docs/cn/ai/external-agent.md +++ b/docs/cn/ai/external-agent.md @@ -8,22 +8,24 @@ ChatLab 可以让 Codex、Claude Code、HermesAgent 等 AI Agent 在本地查询 ## 开始使用 -需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和分析 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-analyze-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-analyze -g ``` +各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-analyze-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + 安装后,可以在 Codex、Claude Code、Cursor 等外部 Agent 中直接说: ```text -chatlab-analyze-cn 帮我分析我和小红的聊天记录 +chatlab-analyze 帮我分析我和小红的聊天记录 ``` 这个技能会引导 Agent 先执行 `clb manifest` 获取机读命令清单,再用安全的 `--format agent/json` 查询聊天记录。 -`chatlab-analyze-cn` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import-cn` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。 +`chatlab-analyze` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。 ## 命令总览 @@ -65,7 +67,13 @@ agent/json 模式下 stdout 只包含一个 JSON 响应信封,日志一律走 "ok": true, "command": "messages.search", "data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 老王: 今年五一去旅游吧..." }, - "meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 } + "meta": { + "totalHits": 2, + "returnedHits": 2, + "hasMore": false, + "preprocess": { "desensitized": true }, + "apiVersion": 1 + } } ``` diff --git a/docs/cn/contributing/ai-workflow.md b/docs/cn/contributing/ai-workflow.md index 3d4b9a79..8434c0ce 100644 --- a/docs/cn/contributing/ai-workflow.md +++ b/docs/cn/contributing/ai-workflow.md @@ -13,7 +13,7 @@ title: AI 协作工作流 | --- | --- | --- | | `AGENTS.md` | 跨任务稳定约定、项目入口、验证命令 | 保持精简,只保留会影响决策的信息 | | `docs/cn/contributing/` | 公开开发细则 | 按任务引用,公开 PR 可独立理解 | -| `skills/*/SKILL.md` | 供外部 Agent 使用的分析、导入、转换流程 | 每个技能一个职责,中英文独立分发并保持语义一致 | +| `skills/*/SKILL.md` | 供外部 Agent 使用的分析、导入、转换流程 | 每个技能一个统一名称,使用简短双语描述与英文正文;回答遵循用户语言 | | `skills/*/references/`、`scripts/` | 格式契约与严格验证器 | 按需读取;确定性校验交给脚本 | | `.github/workflows/` | CI、构建、发布及产物清理 | 保留真实质量门禁,不承载模型提示词 | | 可选的本地规则和私有技能 | 个人协作偏好与维护任务 | 不覆盖用户当前意图,不作为公开贡献前提 | diff --git a/docs/cn/standard/ai-converter.md b/docs/cn/standard/ai-converter.md index faf95693..7e7087b9 100644 --- a/docs/cn/standard/ai-converter.md +++ b/docs/cn/standard/ai-converter.md @@ -16,24 +16,26 @@ outline: deep npm install -g chatlab-cli ``` -再在支持 Skills 的 Agent 环境中安装中文版转换 Skill: +再在支持 Skills 的 Agent 环境中安装转换 Skill: ```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-convert-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + ## 开始转换 把聊天导出的准确路径告诉 Agent: ```text -使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换并导入 ChatLab。 +使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换并导入 ChatLab。 ``` 如果只希望生成转换结果,不立即导入: ```text -使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。 +使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。 ``` Skill 会自动选择电脑上已有的 Node.js、Python 或 Shell 工具,完成以下流程: @@ -59,6 +61,4 @@ clb import "/转换后的文件.jsonl" --dry-run --json 只有严格验证和导入预览都成功,并且源消息数与输出消息数核对一致,才会报告转换成功。转换脚本和输出文件都会保留,方便以后重新转换更新后的聊天记录。 -::: warning 隐私提醒 -请使用能够访问本机文件和终端的 Agent 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。 -::: +::: warning 隐私提醒请使用能够访问本机文件和终端的 Agent 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。::: diff --git a/docs/cn/usage/how-to-import.md b/docs/cn/usage/how-to-import.md index 90d7699f..8c759a7f 100644 --- a/docs/cn/usage/how-to-import.md +++ b/docs/cn/usage/how-to-import.md @@ -19,17 +19,19 @@ ChatLab 支持多种方式导入聊天记录。 适合已经使用 Codex、Claude Code 等各类 AI Agent,希望让 Agent 代为执行导入的用户。 -需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和导入 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-import-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + 然后直接告诉 Agent: ```text -chatlab-import-cn 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab +chatlab-import 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab ``` Agent 会先在后台预览,预览成功后自动新建或增量导入,无需再次确认。 diff --git a/docs/en/ai/external-agent.md b/docs/en/ai/external-agent.md index 84323cb7..5e94a609 100644 --- a/docs/en/ai/external-agent.md +++ b/docs/en/ai/external-agent.md @@ -8,13 +8,15 @@ ChatLab lets AI agents such as Codex, Claude Code, and HermesAgent query and ana ## Get started -Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the English Skill: +Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the analysis skill: ```bash npm install -g chatlab-cli npx skills add ChatLab/ChatLab --skill chatlab-analyze -g ``` +The same skill works across languages; ask in your preferred language. + After installation, ask Codex, Claude Code, Cursor, or another external agent: ```text @@ -65,7 +67,13 @@ In agent/json mode stdout contains exactly one JSON envelope; logs go to stderr: "ok": true, "command": "messages.search", "data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 Wang: how about a trip on May Day..." }, - "meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 } + "meta": { + "totalHits": 2, + "returnedHits": 2, + "hasMore": false, + "preprocess": { "desensitized": true }, + "apiVersion": 1 + } } ``` diff --git a/docs/en/standard/ai-converter.md b/docs/en/standard/ai-converter.md index ff294dd9..f09a158f 100644 --- a/docs/en/standard/ai-converter.md +++ b/docs/en/standard/ai-converter.md @@ -22,6 +22,8 @@ Install the conversion skill in a skill-compatible agent environment: npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +The same skill works across languages; ask in your preferred language. + ## Convert an export Give the agent the exact export path: @@ -59,6 +61,4 @@ clb import "/absolute/path/to/converted.jsonl" --dry-run --json Success requires strict validation, a successful import preview, and matching source/output message counts. The converter and outputs remain available so a newer export can be converted again. -::: warning Privacy -Use an agent that can work with local files and a local terminal. Do not upload a complete chat export to an online chat box. When an unusual text format requires sampling, inspect the smallest possible range and mask message bodies. -::: +::: warning Privacy Use an agent that can work with local files and a local terminal. Do not upload a complete chat export to an online chat box. When an unusual text format requires sampling, inspect the smallest possible range and mask message bodies. ::: diff --git a/docs/en/usage/how-to-import.md b/docs/en/usage/how-to-import.md index 3bd707c8..14c45152 100644 --- a/docs/en/usage/how-to-import.md +++ b/docs/en/usage/how-to-import.md @@ -19,13 +19,15 @@ The homepage also supports incremental imports. When a new file matches an impor Use this when you already work with Codex, Claude Code, or another AI agent and want the agent to handle the import for you. -Node.js 22.19 or newer is required. Install ChatLab CLI and the English skill: +Node.js 22.19 or newer is required. Install ChatLab CLI and the import skill: ```bash npm install -g chatlab-cli npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +The same skill works across languages; ask in your preferred language. + Then ask the agent: ```text diff --git a/docs/tw/standard/ai-converter.md b/docs/tw/standard/ai-converter.md index 45a37f50..10b38f55 100644 --- a/docs/tw/standard/ai-converter.md +++ b/docs/tw/standard/ai-converter.md @@ -16,24 +16,26 @@ outline: deep npm install -g chatlab-cli ``` -再於支援 Skills 的 Agent 環境安裝中文版轉換 Skill: +再於支援 Skills 的 Agent 環境安裝轉換 Skill: ```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +各語言共用同一個技能,可以直接用繁體中文提出需求。曾安裝 `chatlab-convert-cn` 時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。 + ## 開始轉換 把聊天匯出的準確路徑告訴 Agent: ```text -使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。 +使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。 ``` 如果只希望產生轉換結果,不立即匯入: ```text -使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。 +使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。 ``` Skill 會自動選擇電腦上已有的 Node.js、Python 或 Shell 工具,完成以下流程: @@ -59,6 +61,4 @@ clb import "/轉換後的檔案.jsonl" --dry-run --json 只有嚴格驗證和匯入預覽都成功,而且來源訊息數與輸出訊息數核對一致,才會回報轉換成功。轉換腳本和輸出檔案都會保留,方便之後重新轉換更新後的聊天記錄。 -::: warning 隱私提醒 -請使用能存取本機檔案和終端的 Agent 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。 -::: +::: warning 隱私提醒請使用能存取本機檔案和終端的 Agent 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。::: diff --git a/docs/tw/usage/how-to-import.md b/docs/tw/usage/how-to-import.md index 3c5f2720..972c0315 100644 --- a/docs/tw/usage/how-to-import.md +++ b/docs/tw/usage/how-to-import.md @@ -19,17 +19,19 @@ ChatLab 支援多種方式匯入聊天記錄。 適合已經使用 Codex、Claude Code 等各類 AI Agent,希望讓 Agent 代為執行匯入的使用者。 -需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和匯入 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +各語言共用同一個技能,可以直接用繁體中文提出需求。曾安裝 `chatlab-import-cn` 時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。 + 然後直接告訴 Agent: ```text -chatlab-import-cn 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab +chatlab-import 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab ``` Agent 會先在背景預覽,預覽成功後自動建立或增量匯入,無需再次確認。 diff --git a/scripts/build-chatlab-skill-validator.mjs b/scripts/build-chatlab-skill-validator.mjs index 1aa00b05..50b766e2 100644 --- a/scripts/build-chatlab-skill-validator.mjs +++ b/scripts/build-chatlab-skill-validator.mjs @@ -105,11 +105,9 @@ try { { cwd: rootDir, stdio: 'inherit' } ) - for (const skillName of ['chatlab-convert', 'chatlab-convert-cn']) { - const scriptsDir = path.join(rootDir, 'skills', skillName, 'scripts') - mkdirSync(scriptsDir, { recursive: true }) - copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs')) - } + const scriptsDir = path.join(rootDir, 'skills', 'chatlab-convert', 'scripts') + mkdirSync(scriptsDir, { recursive: true }) + copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs')) } finally { rmSync(tempDir, { recursive: true, force: true }) } diff --git a/skills/chatlab-analyze-cn/SKILL.md b/skills/chatlab-analyze-cn/SKILL.md deleted file mode 100644 index 01726994..00000000 --- a/skills/chatlab-analyze-cn/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: chatlab-analyze-cn -description: 通过 clb CLI 分析本地 ChatLab 聊天记录。当用户要求外部 Agent 检查对话、查找证据、总结话题、比较成员,或基于已导入的 ChatLab 数据分析指定人物或群组关系时使用。 ---- - -# ChatLab 聊天分析 - -通过只读 `clb` CLI 查询和分析已经导入 ChatLab 的聊天记录。 - -导入新文件时,可用则使用 `chatlab-import-cn`;否则参考 `clb import --help`,在写入前预览准确的导入计划。 - -## 工作流程 - -### 1. 准备查询 - -每个任务读取一次当前命令契约;只有目标尚未确定时才列出会话: - -```bash -clb manifest -clb sessions list --format json -``` - -复用对话中已确定的会话、成员和时间范围。仅在候选项和上下文仍无法消除关键歧义时询问。CLI 不可用时说明缺少的能力;安装需要授权。 - -### 2. 从专用命令开始 - -先使用能直接回答问题的最简单命令: - -```bash -clb messages search "" --session --format agent -clb messages between --member me --member --session --last 90d --format agent -clb topics list --session --last 30d --format agent -``` - -读取消息文本时使用 `--format agent`;侦察会话、成员、数量和 `--no-content` 搜索等结构时使用 `--format json`。 - -### 3. 补充上下文或统计 - -只有第一步结果不足时再深入: - -```bash -clb messages context --id 1021 --session --window 10 --format agent -clb stats keywords --session --member --last 90d --top 20 --format json -``` - -仍需证据且 `meta.hasMore` 为 true 时,保持相同查询条件并使用 `--cursor ` 继续。问题已回答即可停止;查询不完整时说明覆盖范围,不把单页当成全集。 - -### 4. 最后才使用 SQL - -只有专用命令无法回答时,才使用只读 SQL: - -```bash -clb schema --session --format json -clb sql "SELECT COUNT(*) AS n FROM message" --session --format json -``` - -## 隐私与回答 - -- 查询保持隐私预处理启用,不使用 `--raw` 或修改数据、配置。另有明确导入请求时按导入流程处理。 -- 绝不泄露完整聊天记录。ChatLab 的安全输出会应用用户配置的隐私预处理。 -- 用 `[#1021]`、`[#1021*]` 或 `[#1021-1024]` 引用证据;`messages context --id` 只接受单个 ID,合并范围只用于展示。 -- 先直接回答,说明实际查询的会话和时间范围,再区分观察到的事实与解释。 -- 分析关系时不要过度推断情绪意图。仅在修正方式明确时遵循 `error.hint`。 -- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和证据标记保持原样。 diff --git a/skills/chatlab-analyze-cn/agents/openai.yaml b/skills/chatlab-analyze-cn/agents/openai.yaml deleted file mode 100644 index cce7b004..00000000 --- a/skills/chatlab-analyze-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天分析' - short_description: '通过 ChatLab CLI 安全查询并分析本地聊天记录' - default_prompt: '使用 $chatlab-analyze-cn 分析我在 ChatLab 中的本地聊天记录。' diff --git a/skills/chatlab-analyze/SKILL.md b/skills/chatlab-analyze/SKILL.md index 394eaace..6d64eb0d 100644 --- a/skills/chatlab-analyze/SKILL.md +++ b/skills/chatlab-analyze/SKILL.md @@ -1,12 +1,15 @@ --- name: chatlab-analyze -description: Analyze local ChatLab chat records through the clb CLI. Use when the user asks an external agent to inspect conversations, find evidence, summarize topics, compare members, or analyze a named relationship from imported ChatLab data. +description: >- + Query and analyze imported ChatLab chats with clb: find evidence, summarize topics, and compare activity or relationships. 查询和分析 ChatLab 已导入聊天记录:查找证据、总结话题、比较活跃度或关系。 --- # ChatLab Analyze Query and analyze records already imported into ChatLab through the read-only `clb` CLI. +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. + For importing a new chat export, use `chatlab-import` when available; otherwise follow `clb import --help` and preview the exact import before writing. ## Workflow diff --git a/skills/chatlab-analyze/agents/openai.yaml b/skills/chatlab-analyze/agents/openai.yaml index 1bd7f9f3..f74d8be8 100644 --- a/skills/chatlab-analyze/agents/openai.yaml +++ b/skills/chatlab-analyze/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Analyze' - short_description: 'Safely query and analyze local chats with ChatLab CLI' + display_name: 'ChatLab Analyze · 聊天分析' + short_description: 'Query and analyze local chats · 查询和分析本地聊天记录' default_prompt: 'Use $chatlab-analyze to analyze my local ChatLab chat records.' diff --git a/skills/chatlab-convert-cn/SKILL.md b/skills/chatlab-convert-cn/SKILL.md deleted file mode 100644 index c0cf7d4e..00000000 --- a/skills/chatlab-convert-cn/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: chatlab-convert-cn -description: 编写可复用的本地脚本,将不受支持的聊天导出转换并验证为 ChatLab JSONL 或 JSON。用于未知格式适配,不用于聊天分析或直接导入已支持的文件。 ---- - -# ChatLab 聊天转换 - -在不修改源文件的前提下转换暂不支持的聊天导出。优先交付可复用的转换脚本和经过验证的 JSONL,而不是一次性改写文件。 - -## 不可省略的规则 - -- 始终以只读方式处理源文件。脚本、样本、解压文件和输出写入独立工作目录;用户指定输出位置时遵循该位置。 -- 聊天数据只留在本机。不要上传到网络服务,也不要在 AI 可见的终端输出中打印消息正文。 -- 先检查结构:字段名、列名、值类型、数量、时间戳形态和脱敏样本。诊断输出中的消息正文替换为类型和长度。 -- 绝不静默跳过损坏或不支持的源记录。应携带源行号/索引失败,或保留为消息类型 `99` 并明确统计映射数量。 -- 未经允许不要安装依赖或系统工具。优先使用已有 Node.js/Python 标准库;Shell 只用于检查和编排。 -- 用户没有明确要求导入时,不要写入 ChatLab。用户明确说“转换并导入”,则所有门禁通过后无需二次确认。 - -## 工作流程 - -### 1. 确认源文件和 CLI - -从请求和对话中确定源路径,仍有歧义时才询问。命令中的路径加引号;环境能力每个任务检查一次,任务内复用结果。 - -```bash -clb --help -clb validate --help -clb formats -clb import "/absolute/path/to/source" --dry-run --json -``` - -如果 dry-run 已识别源文件,说明无需转换。只有用户要求导入时才继续,可用则使用 `chatlab-import-cn`,否则按下文预览和写入步骤执行。 - -`clb validate` 不可用时,直接使用 `scripts/validate-chatlab.mjs` 继续转换,不因推荐安装而暂停。内置验证器需要 Node.js 22.19 或更高版本;旧 CLI 可能仍支持 `formats` 和导入 dry-run,按实际可用能力执行。完全没有 CLI 时,说明无法检查原生格式支持和导入就绪状态。 - -两个严格验证器均无法运行时,尽可能完成转换器并将产物标记为尚未验证;不能用肉眼检查替代严格验证。只有缺失的运行时或 CLI 阻塞用户要求的结果时,才请求安装授权。 - -### 2. 在不暴露正文的前提下检查结构 - -先检查文件元信息、编码、压缩包目录、JSON 字段、CSV 表头、XML/HTML 元素名或数据库表结构。不要运行会打印原始消息列的 `cat`、无限制 `head` 或 SQL 查询。 - -确实需要样本值时,编写一个简短的本地检查脚本,只输出: - -- 字段名和数据类型; -- 记录数和空值数; -- 时间戳与标识符形态; -- `` 这样的正文掩码; -- 少量结构不同的记录。 - -压缩包要解压到独立工作目录,并拒绝逃逸该目录的条目。数据库必须只读打开。 - -### 3. 确定字段映射 - -编写转换器前,阅读 [references/chatlab-format.md](references/chatlab-format.md) 的字段契约与所选输出格式。记录源字段到 ChatLab 字段的映射,包括: - -- 会话边界以及群聊/私聊类型; -- 稳定的成员身份和本人身份; -- 时间戳单位与时区; -- 消息类型、正文、原始消息 ID 和回复关系; -- 源记录总数,以及无法完全表达的记录。 - -只有当歧义会改变身份、会话边界、时间含义或本人归属时才询问用户;这些字段不能猜测。 - -一个 ChatLab 文件只能表示一个会话。如果源文件包含多个会话,要使用防冲突且确定性的文件名分别输出,绝不能误合并。 - -### 4. 编写确定性的转换器 - -默认输出 JSONL。只有源文件较小、天然结构化,并且可以明确安全地放入内存时才使用 JSON。 - -使用已安装且最适合当前任务的 Node.js 或 Python;不按源格式强制指定语言。 - -转换器必须: - -- 通过参数接收输入、输出路径,不写死本机路径; -- 保持源文件不变,并拒绝覆盖源文件; -- 先写临时输出,成功后再重命名; -- 保留源顺序,或按时间戳加稳定的源序号排序; -- 优先保留源 ID;没有消息 ID 时省略可选字段,只有回复关联需要时才生成确定性 ID; -- 缺少成员 ID 时根据稳定身份字段确定性生成,绝不能使用随机值; -- 大文件流式处理,进度输出不得包含消息正文; -- 解析失败时以非零状态退出,并输出源记录数、输出数和跳过数汇总。 - -`skipped` 默认为零。把未知消息映射为类型 `99` 属于保留,不计为跳过。 - -### 5. 先证明小样本 - -大文件或映射尚不确定时,先转换并验证受限样本;小文件可以直接全量转换验证。有 CLI 时运行: - -```bash -clb validate "/absolute/path/to/sample.jsonl" --json -``` - -没有 CLI 时,定位本 `SKILL.md` 所在目录,并使用随 Skill 分发的验证器: - -```bash -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/sample.jsonl" -``` - -修复全部 error 后才能全量转换。逐项检查 warning 并说明为什么可以接受,不能自动忽略。 - -### 6. 全量转换并双重验证 - -运行完整转换,然后使用 CLI 或内置验证器验证每一个输出文件。有 CLI 时继续执行导入 dry-run: - -```bash -clb validate "/absolute/path/to/converted.jsonl" --json -clb import "/absolute/path/to/converted.jsonl" --dry-run --json -``` - -没有 CLI 时运行: - -```bash -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/converted.jsonl" -``` - -按验证能力区分结果,不能混用结论: - -- **格式验证通过**:CLI 或内置严格验证器返回 `ok: true`,转换器输出消息数等于验证器消息数,源消息数等于输出消息数加上用户明确接受的跳过数,且没有隐藏源解析错误; -- **导入验证通过**:在格式验证通过的基础上,`clb import --dry-run --json` 也返回 `ok: true`。 - -没有 CLI 时只能报告“格式验证通过”,并提醒用户之后可以安装 CLI 完成 dry-run,或将结果文件拖入 ChatLab;不能声称已经通过导入验证。 - -### 7. 仅在用户要求时导入 - -如果用户只要求转换,在当前环境可完成的验证后停止。报告输出路径、脚本路径、会话数、成员数、消息数、跳过数、警告、映射限制和实际达到的验证级别,不要引用消息正文。 - -如果用户明确要求导入,使用同一个已验证文件移除 `--dry-run`: - -```bash -clb import "/absolute/path/to/converted.jsonl" --json -``` - -如果此时仍没有 CLI,说明自动导入需要 `chatlab-cli`,建议安装并征得用户同意;不能把“格式验证通过”当成已经导入。 - -存在多个输出时,逐个预览并独立导入。报告每个结果的会话 ID 和数量;不要删除转换脚本或转换结果。 diff --git a/skills/chatlab-convert-cn/agents/openai.yaml b/skills/chatlab-convert-cn/agents/openai.yaml deleted file mode 100644 index 52bbfa83..00000000 --- a/skills/chatlab-convert-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天转换' - short_description: '将暂不支持的聊天记录转换并验证为 ChatLab 标准格式' - default_prompt: '使用 $chatlab-convert-cn 将这个不受支持的聊天导出转换为经过验证的 ChatLab JSONL 文件。' diff --git a/skills/chatlab-convert-cn/references/chatlab-format.md b/skills/chatlab-convert-cn/references/chatlab-format.md deleted file mode 100644 index 6f4290f9..00000000 --- a/skills/chatlab-convert-cn/references/chatlab-format.md +++ /dev/null @@ -1,144 +0,0 @@ -# ChatLab 转换协议 - -生成 ChatLab Format `0.0.2` 时遵循本协议。这里只保留转换器需要的内容;生成文件以 ChatLab CLI 或本 Skill 内置严格验证器的结果为准: - -```bash -clb validate "/absolute/path/to/output.jsonl" --json -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/output.jsonl" -``` - -两条命令按环境选择其一,不需要重复运行;内置验证器只验证格式,不代替 `clb import --dry-run --json` 的导入验证。 - -## 核心约束 - -- 一个输出文件只表示一个会话。 -- 使用 UTF-8。时间统一使用整数型 Unix **秒级**时间戳,绝不能使用毫秒。 -- `meta.type` 只能是 `group` 或 `private`。平台使用稳定的小写标识,例如 `qq`、`weixin` 或源产品名称。 -- `sender` 和 `ownerId` 引用成员的 `platformId`。 -- 保留每一条源记录。无法识别的消息映射为类型 `99`,不能丢弃。 -- 有原始成员 ID、消息 ID 时原样保留;即使源数据是数字,输出 ID 也使用字符串。 -- `platformMessageId` 可省略,但出现时必须唯一;`replyToMessageId` 引用其中一个消息 ID。 -- 消息按 `timestamp` 升序排列;时间相同时保留稳定的源序号。 -- `accountName` 表示账号名称,`groupNickname` 表示该会话中的昵称。 - -## 推荐的 JSONL 结构 - -每行写一个紧凑 JSON 对象。第一个非注释数据行是唯一的 `header`,随后写全部 `member`,最后写 `message`。 - -```jsonl -{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1711468800,"generator":"local-converter"},"meta":{"name":"示例群聊","platform":"example","type":"group","groupId":"group-1","ownerId":"member-1"}} -{"_type":"member","platformId":"member-1","accountName":"小红","groupNickname":"小红","aliases":["红红"],"roles":[{"id":"owner"}]} -{"_type":"member","platformId":"member-2","accountName":"小明"} -{"_type":"message","platformMessageId":"message-1","sender":"member-1","accountName":"小红","groupNickname":"小红","timestamp":1711468800,"type":0,"content":"你好"} -{"_type":"message","platformMessageId":"message-2","replyToMessageId":"message-1","sender":"member-2","accountName":"小明","timestamp":1711468810,"type":25,"content":"收到"} -``` - -允许空行和以 `#` 开头的注释行,但转换器通常不应生成它们。只有源数据完全没有成员信息时才省略成员行;只要写了成员行,就必须在第一条消息前写完所有发送者。 - -## JSON 结构 - -只有完整结果可以安全地放入内存时才使用 JSON。`members` 和 `messages` 都是必需数组。 - -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1711468800, - "generator": "local-converter" - }, - "meta": { - "name": "示例私聊", - "platform": "example", - "type": "private", - "ownerId": "member-1" - }, - "members": [ - { "platformId": "member-1", "accountName": "小红" }, - { "platformId": "member-2", "accountName": "小明" } - ], - "messages": [ - { - "sender": "member-1", - "accountName": "小红", - "timestamp": 1711468800, - "type": 0, - "content": "你好" - } - ] -} -``` - -## 字段 - -### 文件头 - -| 路径 | 必需 | 约束 | -| --------------------- | ---- | ---------------------- | -| `chatlab.version` | 是 | 固定为 `0.0.2` | -| `chatlab.exportedAt` | 是 | 整数型 Unix 秒级时间戳 | -| `chatlab.generator` | 否 | 转换器名称 | -| `chatlab.description` | 否 | 不含隐私的简短说明 | -| `meta.name` | 是 | 会话显示名称 | -| `meta.platform` | 是 | 稳定的平台标识 | -| `meta.type` | 是 | `group` 或 `private` | -| `meta.groupId` | 否 | 原始群组/会话 ID | -| `meta.groupAvatar` | 否 | Data URL 或网络 URL | -| `meta.ownerId` | 否 | 导出者的 `platformId` | - -### 成员 - -| 字段 | 必需 | 约束 | -| --------------- | ---- | ------------------------------------------------ | -| `platformId` | 是 | 稳定且唯一的字符串身份 | -| `accountName` | 是 | 账号显示名称 | -| `groupNickname` | 否 | 当前会话中的显示名称 | -| `aliases` | 否 | 字符串数组 | -| `avatar` | 否 | Data URL 或网络 URL | -| `roles` | 否 | 如 `[{"id":"owner"}]`、`[{"id":"admin"}]` 的数组 | - -### 消息 - -| 字段 | 必需 | 约束 | -| ------------------- | ---- | ------------------------------------- | -| `sender` | 是 | 发送者的 `platformId` | -| `accountName` | 是 | 发送时的账号名称 | -| `groupNickname` | 否 | 发送时的群昵称 | -| `timestamp` | 是 | 整数型 Unix 秒级时间戳 | -| `type` | 是 | 使用下表中的数字 | -| `content` | 是 | 字符串或 `null`;尽量保留有用的源表示 | -| `platformMessageId` | 否 | 唯一的原始消息 ID | -| `replyToMessageId` | 否 | 被回复消息的原始 ID | - -## 消息类型 - -| 值 | 含义 | 值 | 含义 | -| ---- | ------------- | ---- | --------- | -| `0` | 文本 | `1` | 图片 | -| `2` | 语音 | `3` | 视频 | -| `4` | 文件 | `5` | 表情/贴纸 | -| `7` | 链接/卡片 | `8` | 位置 | -| `20` | 红包 | `21` | 转账 | -| `22` | 拍一拍/戳一戳 | `23` | 通话 | -| `24` | 分享 | `25` | 回复 | -| `26` | 转发消息 | `27` | 名片 | -| `80` | 系统事件 | `81` | 撤回消息 | -| `99` | 其他/未知 | | | - -媒体消息没有内嵌正文时,在 `content` 中保留有用的占位符或源文件相对路径;只有源数据确实没有任何表示时才使用 `null`。 - -## 身份兜底规则 - -优先使用源平台不可变的用户 ID。确实缺少时,根据同一平台、同一会话内的稳定身份字段确定性生成,例如对规范化账号身份计算 SHA-256,并添加不敏感的前缀。成员顺序可能变化时不能只使用数组下标,绝不能使用随机值。 - -有原始消息 ID 时保留;没有时通常省略 `platformMessageId`。只有回复关系必须依赖消息 ID 时才生成确定性 ID,并纳入会话身份、发送者、秒级时间戳、稳定源序号和正文摘要,确保重复执行结果一致。 - -## 必须核对的数量 - -转换器必须在不输出正文的前提下报告: - -- 源会话数和源消息记录数; -- 输出会话数、成员数和消息数; -- 映射为类型 `99` 的记录数; -- 跳过数和失败数。 - -跳过数和失败数默认为零。只有源消息数等于输出消息数加用户明确接受的跳过数、严格验证通过,并且 `clb import --dry-run --json` 成功,转换才算完成。 diff --git a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs deleted file mode 100644 index a21f8ff0..00000000 --- a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node -// Generated from the canonical ChatLab validator. Do not edit directly. -/* eslint-disable */ -import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==E&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=y("json",t),n;try{n=JSON.parse(await v(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(T(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>_(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>N(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=y("jsonl",t),n=P(e,{encoding:"utf8"}),o=V({input:n,crlfDelay:1/0}),i=0,g=0,A=!1;for await(let w of o){i++;let I=w.trim();if(!I){r.stats.blankLineCount++;continue}if(I.startsWith("#")){r.stats.commentLineCount++;continue}g++;let l;try{l=JSON.parse(I)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&l._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),T(r,l.chatlab,l.meta,{line:i});break;case"member":A&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),_(r,l,`line[${i}]`,{line:i});break;case"message":A=!0,N(r,l,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function L(e){let t=await k(e),r=D(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=y("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var O=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} -`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-convert/SKILL.md b/skills/chatlab-convert/SKILL.md index 673873f2..7d35b93b 100644 --- a/skills/chatlab-convert/SKILL.md +++ b/skills/chatlab-convert/SKILL.md @@ -1,12 +1,15 @@ --- name: chatlab-convert -description: Convert unsupported local chat exports into validated ChatLab JSONL or JSON with a reproducible local script. Use for unknown-format adaptation, not analysis or direct import of already supported files. +description: >- + Convert unsupported local chat exports into validated ChatLab JSONL or JSON with a reusable local script. 用可复用的本地脚本,将不受支持的聊天导出转换并验证为 ChatLab JSONL 或 JSON。 --- # ChatLab Convert Convert an unsupported local export without modifying the source. Prefer a reproducible script and a validated JSONL result over a one-off rewritten file. +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. + ## Non-negotiable rules - Keep every source file read-only. Write scripts, samples, extracted archives, and output to a separate working directory, using the user's output location when specified. diff --git a/skills/chatlab-convert/agents/openai.yaml b/skills/chatlab-convert/agents/openai.yaml index fb5d72c7..4c4215d1 100644 --- a/skills/chatlab-convert/agents/openai.yaml +++ b/skills/chatlab-convert/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Convert' - short_description: 'Convert unsupported chat exports into ChatLab format' + display_name: 'ChatLab Convert · 聊天转换' + short_description: 'Convert unsupported chat exports · 转换不受支持的聊天格式' default_prompt: 'Use $chatlab-convert to convert this unsupported chat export into a validated ChatLab JSONL file.' diff --git a/skills/chatlab-convert/scripts/validate-chatlab.mjs b/skills/chatlab-convert/scripts/validate-chatlab.mjs index a21f8ff0..8c2bd1b1 100644 --- a/skills/chatlab-convert/scripts/validate-chatlab.mjs +++ b/skills/chatlab-convert/scripts/validate-chatlab.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node // Generated from the canonical ChatLab validator. Do not edit directly. /* eslint-disable */ -import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==E&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=y("json",t),n;try{n=JSON.parse(await v(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(T(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>_(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>N(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=y("jsonl",t),n=P(e,{encoding:"utf8"}),o=V({input:n,crlfDelay:1/0}),i=0,g=0,A=!1;for await(let w of o){i++;let I=w.trim();if(!I){r.stats.blankLineCount++;continue}if(I.startsWith("#")){r.stats.commentLineCount++;continue}g++;let l;try{l=JSON.parse(I)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&l._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),T(r,l.chatlab,l.meta,{line:i});break;case"member":A&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),_(r,l,`line[${i}]`,{line:i});break;case"message":A=!0,N(r,l,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function L(e){let t=await k(e),r=D(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=y("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var O=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} -`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} +import{existsSync as K}from"node:fs";import q from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as L}from"node:fs/promises";import{extname as k}from"node:path";import{createInterface as D}from"node:readline";var x="0.0.2",C=["0.0.1",x];function f(e){return C.includes(e)}var y=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(y||{});var G=100,V=1e11,F=new Set(Object.values(y).filter(e=>typeof e=="number")),A="SYSTEM",$=new Set([80,81]);function I(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){l(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function T(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),l(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function _(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),T(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==A&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=I("json",t),n;try{n=JSON.parse(await P(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(v(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>E(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>_(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=I("jsonl",t),n=O(e,{encoding:"utf8"}),o=D({input:n,crlfDelay:1/0}),i=0,g=0,R=!1;for await(let M of o){i++;let h=M.trim();if(!h){r.stats.blankLineCount++;continue}if(h.startsWith("#")){r.stats.commentLineCount++;continue}g++;let c;try{c=JSON.parse(h)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(c)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&c._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),c._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),v(r,c.chatlab,c.meta,{line:i});break;case"member":R&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),E(r,c,`line[${i}]`,{line:i});break;case"message":R=!0,_(r,c,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function N(e){let t=await L(e),r=k(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=I("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var w=process.argv[2];function S(e){process.stdout.write(`${JSON.stringify(e)} +`)}if(!w)S({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=q.resolve(w);if(!K(e))S({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await N(e);S(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){S({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-import-cn/SKILL.md b/skills/chatlab-import-cn/SKILL.md deleted file mode 100644 index 00630ab5..00000000 --- a/skills/chatlab-import-cn/SKILL.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: chatlab-import-cn -description: 通过 clb 预览并导入本地聊天导出文件,支持重新导入和增量更新。用于导入请求,不用于只读分析或未知格式转换。 ---- - -# ChatLab 聊天导入 - -通过 `clb` CLI 导入本地聊天导出文件。始终先预览准确的写入计划。将用户明确提出的导入请求视为授权,预览成功后继续执行,不再请求二次确认。 - -分析已有记录时,可用则使用 `chatlab-analyze-cn`;源格式不受支持时,可用则使用 `chatlab-convert-cn`。受支持格式的导入不依赖这两个技能。 - -## 工作流程 - -1. 检查 CLI,并确认唯一、准确的文件路径: - -```bash -clb --help -``` - -如果 CLI 未安装,告诉用户运行 `npm install -g chatlab-cli`。未经批准不要自行安装软件。复用用户指定的文件和目标,路径仍有歧义时才询问。每条命令中的路径都要加引号。 - -2. 只读预览导入计划: - -```bash -clb import "/absolute/path/to/chat-export.json" --dry-run --json -``` - -如果用户选择了已有会话,在预览和正式导入中都加入 `--session-id `。 - -3. 只总结新建或更新模式、目标会话 ID、扫描消息数、新增消息数、重复消息数,以及匹配方式或新建原因。不要引用消息正文。 - -4. 确认 `ok: true`,从 `data` 读取计划后决定: - -- 用户只要求预览或检查时,到此停止,不写入数据。 -- `importMode` 为 `incremental` 且 `newMessageCount` 为 `0` 时,告诉用户会话已经是最新状态,然后停止。 -- `importMode` 为 `incremental` 且存在新增消息时,无需再次确认,自动继续。 -- `importMode` 为 `created` 时,自动继续。如果 `createReason` 为 `ambiguous`,通过新建会话保护已有会话,并在最终结果中说明原因。 -- 结果不足以确定安全操作时,停止并说明缺少的信息。 - -5. 根据上一步可以写入时,使用相同文件、目标和格式移除 `--dry-run`: - -```bash -clb import "/absolute/path/to/chat-export.json" --json -``` - -根据最终 JSON 信封报告结果会话 ID、导入模式、新增消息数和重复消息数。 - -## 安全边界 - -- 绝不跳过预览、直接编辑 ChatLab 数据库、删除或合并会话。 -- 绝不泄露完整聊天导出文件或消息正文。 -- 绝不编造文件路径、解析格式或会话 ID。 -- 仅在修正方式明确时遵循 `error.hint`。 -- `FILE_NOT_FOUND`、`INVALID_SESSION_ID` 优先从上下文修正,无法确定时询问;`UNRECOGNIZED_FORMAT` 查看 `clb formats`;`IMPORT_IN_PROGRESS` 在现有导入完成后重试一次,仍阻塞则报告,不无限轮询。 -- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和错误码保持原样。 diff --git a/skills/chatlab-import-cn/agents/openai.yaml b/skills/chatlab-import-cn/agents/openai.yaml deleted file mode 100644 index 60b92473..00000000 --- a/skills/chatlab-import-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天导入' - short_description: '通过 ChatLab CLI 安全预览并导入本地聊天记录' - default_prompt: '使用 $chatlab-import-cn 安全预览并将这个聊天导出文件导入 ChatLab。' diff --git a/skills/chatlab-import/SKILL.md b/skills/chatlab-import/SKILL.md index 1bc4e568..7584f284 100644 --- a/skills/chatlab-import/SKILL.md +++ b/skills/chatlab-import/SKILL.md @@ -1,12 +1,15 @@ --- name: chatlab-import -description: Preview and import local chat exports into ChatLab with clb, including re-imports and incremental updates. Use for import requests, not read-only analysis or unsupported-format conversion. +description: >- + Preview and import supported local chat exports into ChatLab, including incremental updates. 预览并导入 ChatLab 支持的本地聊天导出,支持增量更新。 --- # ChatLab Import Import local chat exports through the `clb` CLI. Always preview the exact write. Treat an explicit request to import as authorization to continue after a successful preview; do not ask for a second confirmation. +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. + For analysis of imported records, use `chatlab-analyze` when available. For an unsupported source, use `chatlab-convert` when available; neither skill is required for a supported import. ## Workflow diff --git a/skills/chatlab-import/agents/openai.yaml b/skills/chatlab-import/agents/openai.yaml index 0b5309f7..1c730735 100644 --- a/skills/chatlab-import/agents/openai.yaml +++ b/skills/chatlab-import/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Import' - short_description: 'Preview and import chat exports with ChatLab CLI' + display_name: 'ChatLab Import · 聊天导入' + short_description: 'Preview and import chat exports · 预览并导入聊天记录' default_prompt: 'Use $chatlab-import to safely preview and import this chat export into ChatLab.' diff --git a/tests/chatlab-skill-validator.test.ts b/tests/chatlab-skill-validator.test.ts index e765baa8..081c31a2 100644 --- a/tests/chatlab-skill-validator.test.ts +++ b/tests/chatlab-skill-validator.test.ts @@ -7,10 +7,7 @@ import { after, before, describe, it } from 'node:test' import { fileURLToPath } from 'node:url' const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const VALIDATOR_PATHS = [ - 'skills/chatlab-convert/scripts/validate-chatlab.mjs', - 'skills/chatlab-convert-cn/scripts/validate-chatlab.mjs', -] +const VALIDATOR_PATH = path.join(ROOT_DIR, 'skills/chatlab-convert/scripts/validate-chatlab.mjs') let tempDir = '' @@ -31,8 +28,8 @@ function createDocument(sender: string, type: number): string { }) } -function runValidator(validatorPath: string, documentPath: string) { - const result = spawnSync(process.execPath, [path.join(ROOT_DIR, validatorPath), documentPath], { +function runValidator(documentPath: string) { + const result = spawnSync(process.execPath, [VALIDATOR_PATH, documentPath], { cwd: ROOT_DIR, encoding: 'utf8', }) @@ -54,30 +51,25 @@ after(() => { fs.rmSync(tempDir, { recursive: true, force: true }) }) -describe('standalone ChatLab skill validators', () => { - for (const validatorPath of VALIDATOR_PATHS) { - it(`${validatorPath} accepts reserved SYSTEM messages`, () => { - const documentPath = path.join(tempDir, `${path.basename(path.dirname(path.dirname(validatorPath)))}-system.json`) - fs.writeFileSync(documentPath, createDocument('SYSTEM', 80), 'utf8') +describe('standalone ChatLab skill validator', () => { + it('accepts reserved SYSTEM messages', () => { + const documentPath = path.join(tempDir, 'system.json') + fs.writeFileSync(documentPath, createDocument('SYSTEM', 80), 'utf8') - const result = runValidator(validatorPath, documentPath) + const result = runValidator(documentPath) - assert.equal(result.status, 0) - assert.equal(result.output.ok, true) - }) + assert.equal(result.status, 0) + assert.equal(result.output.ok, true) + }) - it(`${validatorPath} rejects SYSTEM as a regular message sender`, () => { - const documentPath = path.join( - tempDir, - `${path.basename(path.dirname(path.dirname(validatorPath)))}-invalid-system.json` - ) - fs.writeFileSync(documentPath, createDocument('SYSTEM', 0), 'utf8') + it('rejects SYSTEM as a regular message sender', () => { + const documentPath = path.join(tempDir, 'invalid-system.json') + fs.writeFileSync(documentPath, createDocument('SYSTEM', 0), 'utf8') - const result = runValidator(validatorPath, documentPath) + const result = runValidator(documentPath) - assert.equal(result.status, 1) - assert.equal(result.output.ok, false) - assert.ok(result.output.data?.issues.some((issue) => issue.code === 'INVALID_SYSTEM_SENDER_TYPE')) - }) - } + assert.equal(result.status, 1) + assert.equal(result.output.ok, false) + assert.ok(result.output.data?.issues.some((issue) => issue.code === 'INVALID_SYSTEM_SENDER_TYPE')) + }) })