From b0380a66897dcae2cae87eb403ba2959ebd2d749 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 19 Jul 2026 13:34:36 +0800 Subject: [PATCH] feat(skills): support conversion without ChatLab CLI --- package.json | 1 + scripts/build-chatlab-skill-validator.mjs | 115 ++++++++++++++++++ skills/chatlab-convert-cn/SKILL.md | 42 ++++++- .../references/chatlab-format.md | 5 +- .../scripts/validate-chatlab.mjs | 4 + skills/chatlab-convert/SKILL.md | 42 ++++++- .../references/chatlab-format.md | 5 +- .../scripts/validate-chatlab.mjs | 4 + 8 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 scripts/build-chatlab-skill-validator.mjs create mode 100644 skills/chatlab-convert-cn/scripts/validate-chatlab.mjs create mode 100644 skills/chatlab-convert/scripts/validate-chatlab.mjs diff --git a/package.json b/package.json index cef020bd..8c9c1da3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "build:desktop": "pnpm --filter @openchatlab/desktop build", "build:mac": "pnpm --filter @openchatlab/desktop build:mac", "build:win": "pnpm --filter @openchatlab/desktop build:win", + "build:skill-validator": "node scripts/build-chatlab-skill-validator.mjs", "dev:serve": "bash scripts/dev-serve.sh", "dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts", "ensure:server-native": "pnpm --filter chatlab-cli run ensure-native", diff --git a/scripts/build-chatlab-skill-validator.mjs b/scripts/build-chatlab-skill-validator.mjs new file mode 100644 index 00000000..e8558e48 --- /dev/null +++ b/scripts/build-chatlab-skill-validator.mjs @@ -0,0 +1,115 @@ +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const tempDir = mkdtempSync(path.join(tmpdir(), 'chatlab-skill-validator-')) +const entryPath = path.join(tempDir, 'entry.ts') +const outputPath = path.join(tempDir, 'validate-chatlab.mjs') +const validatorPath = path.join(rootDir, 'packages/parser/src/chatlab-validator.ts') + +const entrySource = ` +import { existsSync } from 'node:fs' +import path from 'node:path' +import { validateChatLabFile } from ${JSON.stringify(validatorPath)} + +const input = process.argv[2] + +function write(envelope: unknown): void { + process.stdout.write(\`${'${JSON.stringify(envelope)}'}\\n\`) +} + +if (!input) { + write({ + ok: false, + command: 'validate', + error: { + code: 'MISSING_FILE', + message: 'Usage: node validate-chatlab.mjs ', + }, + meta: { apiVersion: 1, standalone: true }, + }) + process.exitCode = 2 +} else { + const file = path.resolve(input) + if (!existsSync(file)) { + write({ + ok: false, + command: 'validate', + error: { + code: 'FILE_NOT_FOUND', + message: \`File not found: ${'${file}'}\`, + hint: 'Pass an absolute path or a path relative to the current working directory.', + }, + meta: { apiVersion: 1, standalone: true }, + }) + process.exitCode = 3 + } else { + try { + const report = await validateChatLabFile(file) + write( + report.valid + ? { + ok: true, + command: 'validate', + data: report, + meta: { apiVersion: 1, standalone: true }, + } + : { + ok: false, + command: 'validate', + data: report, + error: { + code: 'INVALID_CHATLAB_FORMAT', + message: \`Validation found ${'${report.errorCount}'} error(s).\`, + hint: 'Fix every error before importing the file into ChatLab.', + }, + meta: { apiVersion: 1, standalone: true }, + } + ) + if (!report.valid) process.exitCode = 1 + } catch (error) { + write({ + ok: false, + command: 'validate', + error: { + code: 'VALIDATION_FAILED', + message: error instanceof Error ? error.message : String(error), + }, + meta: { apiVersion: 1, standalone: true }, + }) + process.exitCode = 1 + } + } +} +` + +try { + writeFileSync(entryPath, entrySource) + execFileSync( + 'pnpm', + [ + 'exec', + 'esbuild', + entryPath, + '--bundle', + '--platform=node', + '--format=esm', + '--target=node20', + '--minify', + `--outfile=${outputPath}`, + '--banner:js=#!/usr/bin/env node\n// Generated from the canonical ChatLab validator. Do not edit directly.', + ], + { 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')) + } +} finally { + rmSync(tempDir, { recursive: true, force: true }) +} diff --git a/skills/chatlab-convert-cn/SKILL.md b/skills/chatlab-convert-cn/SKILL.md index 73f6729b..efd1039b 100644 --- a/skills/chatlab-convert-cn/SKILL.md +++ b/skills/chatlab-convert-cn/SKILL.md @@ -35,7 +35,18 @@ chatlab formats chatlab import "/absolute/path/to/source" --dry-run --json ``` -如果 dry-run 已经识别源文件,不要重复转换,改用 `chatlab-import-cn` 完成导入。如果没有 `chatlab validate`,让用户更新 `chatlab-cli`;不能用肉眼检查替代必需的验证器。 +如果 dry-run 已经识别源文件,不要重复转换,改用 `chatlab-import-cn` 完成导入。 + +如果 `chatlab` 不存在,或已有版本没有 `validate` 命令: + +1. 告诉用户未检测到可用的 ChatLab CLI,但仍可使用本 Skill 内置的严格验证器继续转换; +2. 建议安装或更新 CLI,并在执行前征得用户同意:`npm install -g chatlab-cli@latest`; +3. 用户跳过安装、安装失败或当前无法联网时,不要阻断转换,改用 `scripts/validate-chatlab.mjs`; +4. 跳过 `chatlab formats` 和源文件 dry-run,并明确说明无法确认 ChatLab 是否已原生支持该源格式。 + +内置验证器需要 Node.js 20 或更高版本。如果 `node --version` 也不可用,可以继续编写转换器,但必须把结果标记为“尚未验证”,并引导用户安装 Node.js 和 `chatlab-cli`;未经同意不要自行安装。 + +不能用肉眼检查替代 CLI 或内置严格验证器。 ### 2. 在不暴露正文的前提下检查结构 @@ -90,24 +101,43 @@ chatlab import "/absolute/path/to/source" --dry-run --json ### 5. 先证明小样本 -先对受限的本地样本或脚本的 sample 模式运行转换器,然后验证: +先对受限的本地样本或脚本的 sample 模式运行转换器,然后验证。有 CLI 时运行: ```bash chatlab 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 chatlab validate "/absolute/path/to/converted.jsonl" --json chatlab 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`,转换器输出消息数等于验证器消息数,源消息数等于输出消息数加上用户明确接受的跳过数,且没有隐藏源解析错误; +- **导入验证通过**:在格式验证通过的基础上,`chatlab import --dry-run --json` 也返回 `ok: true`。 + +没有 CLI 时只能报告“格式验证通过”,并提醒用户之后可以安装 CLI 完成 dry-run,或将结果文件拖入 ChatLab;不能声称已经通过导入验证。 + +只有同时满足以下条件,才能认为转换结果已经通过完整导入验证: - 严格验证返回 `ok: true`; - 导入 dry-run 返回 `ok: true`; @@ -117,7 +147,7 @@ chatlab import "/absolute/path/to/converted.jsonl" --dry-run --json ### 7. 仅在用户要求时导入 -如果用户只要求转换,在验证和 dry-run 预览后停止。报告输出路径、脚本路径、会话数、成员数、消息数、跳过数、警告和映射限制,不要引用消息正文。 +如果用户只要求转换,在当前环境可完成的验证后停止。报告输出路径、脚本路径、会话数、成员数、消息数、跳过数、警告、映射限制和实际达到的验证级别,不要引用消息正文。 如果用户明确要求导入,使用同一个已验证文件移除 `--dry-run`: @@ -125,4 +155,6 @@ chatlab import "/absolute/path/to/converted.jsonl" --dry-run --json chatlab import "/absolute/path/to/converted.jsonl" --json ``` +如果此时仍没有 CLI,说明自动导入需要 `chatlab-cli`,建议安装并征得用户同意;不能把“格式验证通过”当成已经导入。 + 存在多个输出时,逐个预览并独立导入。报告每个结果的会话 ID 和数量;不要删除转换脚本或转换结果。 diff --git a/skills/chatlab-convert-cn/references/chatlab-format.md b/skills/chatlab-convert-cn/references/chatlab-format.md index 8ce00e24..f1f8cc98 100644 --- a/skills/chatlab-convert-cn/references/chatlab-format.md +++ b/skills/chatlab-convert-cn/references/chatlab-format.md @@ -1,11 +1,14 @@ # ChatLab 转换协议 -生成 ChatLab Format `0.0.2` 时遵循本协议。这里只保留转换器需要的内容;生成文件以严格验证器的结果为准: +生成 ChatLab Format `0.0.2` 时遵循本协议。这里只保留转换器需要的内容;生成文件以 ChatLab CLI 或本 Skill 内置严格验证器的结果为准: ```bash chatlab validate "/absolute/path/to/output.jsonl" --json +node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/output.jsonl" ``` +两条命令按环境选择其一,不需要重复运行;内置验证器只验证格式,不代替 `chatlab import --dry-run --json` 的导入验证。 + ## 核心约束 - 一个输出文件只表示一个会话。 diff --git a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs new file mode 100644 index 00000000..e70b88a4 --- /dev/null +++ b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// Generated from the canonical ChatLab validator. Do not edit directly. +import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];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 v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,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,r,n,t,o={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,o){c(r,n)&&typeof r[n]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(o,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),c(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((o,i)=>{let p=`${n}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,o.id,`${p}.id`,t),m(e,o,"name",`${p}.name`,t)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!d(r)){a(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0){for(let o of e.ownerIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference an explicit member platformId.");for(let o of e.senderIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.")}for(let o of e.replyTargetIds)e.messageIds.has(o)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let t=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,t-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return a(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return d(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((o,i)=>N(n,o,`members[${i}]`,{})):a(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((o,i)=>_(n,o,`messages[${i}]`,{})):a(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(a(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),o=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of o){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{a(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&a(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&a(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&a(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:a(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return a(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} +`)}if(!L)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=z.resolve(L);if(!U(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 r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-convert/SKILL.md b/skills/chatlab-convert/SKILL.md index 04225200..25ccb484 100644 --- a/skills/chatlab-convert/SKILL.md +++ b/skills/chatlab-convert/SKILL.md @@ -35,7 +35,18 @@ chatlab formats chatlab import "/absolute/path/to/source" --dry-run --json ``` -If the dry run recognizes the source, do not convert it. Use `chatlab-import` for the supported file. If `chatlab validate` is unavailable, ask the user to update `chatlab-cli`; do not replace the required validator with visual inspection. +If the dry run recognizes the source, do not convert it. Use `chatlab-import` for the supported file. + +If `chatlab` is missing, or the installed version has no `validate` command: + +1. Tell the user that ChatLab CLI was not detected but conversion can continue with this Skill's bundled strict validator. +2. Recommend installing or updating the CLI, and obtain permission before running `npm install -g chatlab-cli@latest`. +3. If the user skips installation, installation fails, or the network is unavailable, continue with `scripts/validate-chatlab.mjs`. +4. Skip `chatlab formats` and the source dry run, and state that native ChatLab support for the source format could not be checked. + +The bundled validator requires Node.js 20 or later. If `node --version` is also unavailable, the converter may still be written, but mark the result as “not yet validated” and guide the user to install Node.js and `chatlab-cli`. Do not install either without permission. + +Do not replace either the CLI validator or bundled strict validator with visual inspection. ### 2. Inspect without exposing content @@ -90,24 +101,43 @@ Default `skipped` to zero. Mapping an unknown message to type `99` is preservati ### 5. Prove a small sample -Run the converter on a bounded local sample or sample mode first. Validate that output: +Run the converter on a bounded local sample or sample mode first. With the CLI available, validate that output with: ```bash chatlab validate "/absolute/path/to/sample.jsonl" --json ``` +Without the CLI, locate the directory containing this `SKILL.md` and use the bundled validator: + +```bash +node "/absolute/path/to/chatlab-convert/scripts/validate-chatlab.mjs" "/absolute/path/to/sample.jsonl" +``` + Fix every validation error before running the full conversion. Review warnings and explain why each is acceptable; do not ignore them automatically. ### 6. Convert and validate everything -Run the full converter, then validate every generated file: +Run the full converter, then validate every generated file with the CLI or bundled validator. With the CLI available, also run the import dry run: ```bash chatlab validate "/absolute/path/to/converted.jsonl" --json chatlab import "/absolute/path/to/converted.jsonl" --dry-run --json ``` -Require all of the following before calling conversion successful: +Without the CLI, run: + +```bash +node "/absolute/path/to/chatlab-convert/scripts/validate-chatlab.mjs" "/absolute/path/to/converted.jsonl" +``` + +Keep the result levels distinct: + +- **Format validated**: the CLI or bundled strict validator returns `ok: true`; converter and validator message counts match; source messages equal output messages plus explicitly accepted skipped records; and no source parse error was hidden. +- **Import validated**: format validation passed and `chatlab import --dry-run --json` also returns `ok: true`. + +Without the CLI, report only “format validated.” Tell the user they can install the CLI later to run the dry-run or drag the result into ChatLab. Do not claim import validation passed. + +Require all of the following before calling the result fully import validated: - strict validation returns `ok: true`; - the import dry run returns `ok: true`; @@ -117,7 +147,7 @@ Require all of the following before calling conversion successful: ### 7. Import only when requested -If the user asked only to convert, stop after validation and dry-run preview. Report output and script paths, conversations, members, messages, skipped records, warnings, and mapping limitations without quoting messages. +If the user asked only to convert, stop after all validation available in the current environment. Report output and script paths, conversations, members, messages, skipped records, warnings, mapping limitations, and the validation level actually reached without quoting messages. If the user explicitly asked to import, run the same validated file without `--dry-run`: @@ -125,4 +155,6 @@ If the user explicitly asked to import, run the same validated file without `--d chatlab import "/absolute/path/to/converted.jsonl" --json ``` +If the CLI is still unavailable, explain that automatic import requires `chatlab-cli`, recommend installation, and obtain permission before installing it. Never treat “format validated” as already imported. + For multiple outputs, preview and import each independently. Report every resulting session ID and count; do not delete the converter or converted files. diff --git a/skills/chatlab-convert/references/chatlab-format.md b/skills/chatlab-convert/references/chatlab-format.md index 9282dcb4..2ee853e9 100644 --- a/skills/chatlab-convert/references/chatlab-format.md +++ b/skills/chatlab-convert/references/chatlab-format.md @@ -1,11 +1,14 @@ # ChatLab conversion contract -Use this contract when generating ChatLab Format `0.0.2`. It is intentionally limited to what a converter needs. The strict validator is authoritative for generated files: +Use this contract when generating ChatLab Format `0.0.2`. It is intentionally limited to what a converter needs. The ChatLab CLI or this Skill's bundled strict validator is authoritative for generated files: ```bash chatlab validate "/absolute/path/to/output.jsonl" --json +node "/absolute/path/to/chatlab-convert/scripts/validate-chatlab.mjs" "/absolute/path/to/output.jsonl" ``` +Choose one command based on the environment; running both is unnecessary. The bundled validator checks the format only and does not replace import validation with `chatlab import --dry-run --json`. + ## Core invariants - One output file represents exactly one conversation. diff --git a/skills/chatlab-convert/scripts/validate-chatlab.mjs b/skills/chatlab-convert/scripts/validate-chatlab.mjs new file mode 100644 index 00000000..e70b88a4 --- /dev/null +++ b/skills/chatlab-convert/scripts/validate-chatlab.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// Generated from the canonical ChatLab validator. Do not edit directly. +import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];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 v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,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,r,n,t,o={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,o){c(r,n)&&typeof r[n]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(o,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&rtypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),c(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((o,i)=>{let p=`${n}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,o.id,`${p}.id`,t),m(e,o,"name",`${p}.name`,t)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!d(r)){a(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp0){for(let o of e.ownerIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference an explicit member platformId.");for(let o of e.senderIds)e.memberIds.has(o)||a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.")}for(let o of e.replyTargetIds)e.messageIds.has(o)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let t=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,t-e.issues.length),stats:e.stats}}async function F(e,r){let n=A("json",r),t;try{t=JSON.parse(await P(e,"utf8"))}catch{return a(n,"error","INVALID_JSON","The file is not valid JSON."),b(n,!0,!1)}return d(t)?(x(n,t.chatlab,t.meta,{}),Array.isArray(t.members)?t.members.forEach((o,i)=>N(n,o,`members[${i}]`,{})):a(n,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(t.messages)?t.messages.forEach((o,i)=>_(n,o,`messages[${i}]`,{})):a(n,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(n,!0)):(a(n,"error","INVALID_ROOT","Expected a top-level JSON object."),b(n,!0,!1))}async function $(e,r){let n=A("jsonl",r),t=O(e,{encoding:"utf8"}),o=D({input:t,crlfDelay:1/0}),i=0,p=0,R=!1;for await(let w of o){i++;let I=w.trim();if(!I){n.stats.blankLineCount++;continue}if(I.startsWith("#")){n.stats.commentLineCount++;continue}p++;let l;try{l=JSON.parse(I)}catch{a(n,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(n,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(p===1&&l._type!=="header"&&a(n,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":n.stats.headerCount>0&&a(n,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),x(n,l.chatlab,l.meta,{line:i});break;case"member":R&&a(n,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),N(n,l,`line[${i}]`,{line:i});break;case"message":R=!0,_(n,l,`line[${i}]`,{line:i});break;default:a(n,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(n,!1)}async function T(e){let r=await k(e),n=V(e).toLowerCase();if(n===".json")return F(e,r.size);if(n===".jsonl")return $(e,r.size);let t=A("unknown",r.size);return a(t,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(t,!1,!1)}var L=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} +`)}if(!L)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=z.resolve(L);if(!U(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 r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}