refactor(skills): unify multilingual skill entrypoints

This commit is contained in:
digua committed 2026-09-06 00:00:36 +08:00
1 parent 7310b0670f
commit 59520a2e61
26 files changed
+96 -490

No files matched your search

+13 -5
View File
@@ -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
}
}
```
+1 -1
View File
@@ -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、构建、发布及产物清理 | 保留真实质量门禁,不承载模型提示词 |
| 可选的本地规则和私有技能 | 个人协作偏好与维护任务 | 不覆盖用户当前意图,不作为公开贡献前提 |
+7 -7
View File
@@ -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 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。:::
+5 -3
View File
@@ -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 会先在后台预览,预览成功后自动新建或增量导入,无需再次确认。
+10 -2
View File
@@ -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
}
}
```
+3 -3
View File
@@ -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. :::
+3 -1
View File
@@ -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
+7 -7
View File
@@ -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 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。:::
+5 -3
View File
@@ -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 會先在背景預覽,預覽成功後自動建立或增量匯入,無需再次確認。
+3 -5
View File
@@ -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 })
}
-64
View File
@@ -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 "<keyword>" --session <session-id> --format agent
clb messages between --member me --member <member> --session <session-id> --last 90d --format agent
clb topics list --session <session-id> --last 30d --format agent
```
读取消息文本时使用 `--format agent`;侦察会话、成员、数量和 `--no-content` 搜索等结构时使用 `--format json`。
### 3. 补充上下文或统计
只有第一步结果不足时再深入:
```bash
clb messages context --id 1021 --session <session-id> --window 10 --format agent
clb stats keywords --session <session-id> --member <member> --last 90d --top 20 --format json
```
仍需证据且 `meta.hasMore` 为 true 时,保持相同查询条件并使用 `--cursor <meta.nextCursor>` 继续。问题已回答即可停止;查询不完整时说明覆盖范围,不把单页当成全集。
### 4. 最后才使用 SQL
只有专用命令无法回答时,才使用只读 SQL:
```bash
clb schema --session <session-id> --format json
clb sql "SELECT COUNT(*) AS n FROM message" --session <session-id> --format json
```
## 隐私与回答
- 查询保持隐私预处理启用,不使用 `--raw` 或修改数据、配置。另有明确导入请求时按导入流程处理。
- 绝不泄露完整聊天记录。ChatLab 的安全输出会应用用户配置的隐私预处理。
- 用 `[#1021]`、`[#1021*]` 或 `[#1021-1024]` 引用证据;`messages context --id` 只接受单个 ID,合并范围只用于展示。
- 先直接回答,说明实际查询的会话和时间范围,再区分观察到的事实与解释。
- 分析关系时不要过度推断情绪意图。仅在修正方式明确时遵循 `error.hint`。
- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和证据标记保持原样。
@@ -1,4 +0,0 @@
interface:
display_name: 'ChatLab 聊天分析'
short_description: '通过 ChatLab CLI 安全查询并分析本地聊天记录'
default_prompt: '使用 $chatlab-analyze-cn 分析我在 ChatLab 中的本地聊天记录。'
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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.'
-135
View File
@@ -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 查询。
确实需要样本值时,编写一个简短的本地检查脚本,只输出:
- 字段名和数据类型;
- 记录数和空值数;
- 时间戳与标识符形态;
- `<TEXT length=42>` 这样的正文掩码;
- 少量结构不同的记录。
压缩包要解压到独立工作目录,并拒绝逃逸该目录的条目。数据库必须只读打开。
### 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 和数量;不要删除转换脚本或转换结果。
@@ -1,4 +0,0 @@
interface:
display_name: 'ChatLab 聊天转换'
short_description: '将暂不支持的聊天记录转换并验证为 ChatLab 标准格式'
default_prompt: '使用 $chatlab-convert-cn 将这个不受支持的聊天导出转换为经过验证的 ChatLab JSONL 文件。'
@@ -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 <output> --dry-run --json` 成功,转换才算完成。
@@ -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.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function c(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!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&&t<M?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function T(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,C(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...n,path:i})),x(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),c(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function _(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),c(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof 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.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===E&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!c(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},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}}
+4 -1
View File
@@ -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.
+2 -2
View File
@@ -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.'
@@ -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.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function c(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!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&&t<M?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function T(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,C(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...n,path:i})),x(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),c(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function _(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),c(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof 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.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===E&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!c(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},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.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function l(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!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&&t<V?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function v(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,f(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${C.join(", ")}.`,{...n,path:i})),T(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),l(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function E(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),l(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof 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.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===A&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!l(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
`)}if(!w)S({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},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}}
-55
View File
@@ -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 <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 字段和错误码保持原样。
@@ -1,4 +0,0 @@
interface:
display_name: 'ChatLab 聊天导入'
short_description: '通过 ChatLab CLI 安全预览并导入本地聊天记录'
default_prompt: '使用 $chatlab-import-cn 安全预览并将这个聊天导出文件导入 ChatLab。'
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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.'
+19 -27
View File
@@ -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'))
})
})