
X Create
- 104 installs
- 316 repo stars
- Updated February 21, 2026
- kangarooking/x-skills
Helps with ai & agent building tasks.
About
x-create is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- x-create
- AI & Agent Building
- AI-coding skill
X Create by the numbers
- 104 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #4,243 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kangarooking/x-skills --skill x-createAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 104 |
|---|---|
| repo stars | ★ 316 |
| Last updated | February 21, 2026 |
| Repository | kangarooking/x-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
X Create
Create viral X posts (short tweets, threads, replies) based on user's persona and post patterns.
First-Time Setup
Check user profile before creating content:
1. Read references/user-profile.md 2. If initialized: false or file doesn't exist → Run onboarding 3. If initialized: true → Proceed to content creation
Onboarding Questions
Ask user these questions using AskUserQuestion tool:
1. 账号定位(领域): 你的X账号主要分享什么内容?
- Options: AI/科技, 创业/商业, 个人成长, 投资理财, Other
2. 目标受众: 你的目标读者是谁?
- Options: 中文用户, 英文用户, 双语用户
3. 人设风格: 你希望塑造什么样的人设?
- Options: 专业严肃, 轻松幽默, 犀利观点, 温暖亲和, Other
After collecting answers, update references/user-profile.md with initialized: true.
Post Types
5 Categories
| Type | Style | Use When | Intent Signals (路由线索) |
|---|---|---|---|
| 高价值干货 | 信息密度高,可收藏 | 教程、工具推荐、方法论 | 目标是收藏/转发;强调可执行清单、工具、步骤 |
| 犀利观点 | 有态度有立场 | 行业评论、反常识观点 | 目标是讨论/对立;需要强立场、对比、反常识 |
| 热点评论 | 快速反应 | 新闻评论、事件点评 | 目标是蹭热度/抢时效;围绕刚发生事件快速解读 |
| 故事洞察 | 个人经历+洞察 | 案例分析、经验复盘 | 目标是共鸣/关注;用具体场景+转折+金句 |
| 技术解析 | 深度技术 | 原理讲解、源码分析 | 目标是建立专业度;解释原理、机制、影响与建议 |
Output Formats
1. 短推文 (≤280 characters) - Single tweet 2. Thread (多条串联) - 3-10 tweets connected 3. 评论回复 - For replying to trending posts
Creation Workflow
Step 1: Load Context
1. Read references/user-profile.md → Get persona, style
2. (Optional) Read state from ~/.claude/skills/x-create/state/
- liked_topics.json (positive samples)
- rejected_topics.json (negative samples)
- events.jsonl (optional)
3. Check assets/templates/{type}/ → Look for user reference posts
4. If no references → Use default patterns from references/post-patterns.mdStep 2: Intent-based Routing
Determine intent first, then choose style and format:
1. Intent → Style (5 categories)
- 收藏/转发导向 → 高价值干货
- 讨论/对立导向 → 犀利观点
- 时效/热点导向 → 热点评论
- 共鸣/关注导向 → 故事洞察
- 专业/技术导向 → 技术解析
2. Style → Output format
- Short tweet: Single insight, quick take, one-liner
- Thread: Multi-point analysis, step-by-step, detailed breakdown
- Reply: Designed to respond to specific post/topic
If user explicitly provides --type, follow it. Otherwise route automatically.
Step 3: Apply Pattern
Read references/post-patterns.md for the specific post type pattern.
Step 4: Generate Content (A/B Variants)
Create two variants by default:
- Variant A: More direct, stronger hook, higher contrast
- Variant B: More structured, more evidence, slightly more neutral
Follow: 1. User's persona style 2. Selected post style pattern 3. Reference examples (if available)
Step 4.5: Humanize Pass(去 AI 味,默认必做)
For each variant, rewrite the text to sound like a real person on X while keeping meaning and claims unchanged:
- Delete filler + chatbot politeness: avoid "当然/希望这对你有帮助/让我们来深入探讨"
- Remove grand/marketing tone: avoid "标志着/至关重要/不断演变的格局/彰显/赋能/令人叹为观止"
- No vague attribution: avoid "专家认为/行业报告显示" unless you provide a specific source; otherwise rewrite as "我观察到/我的判断是..."
- Reduce connective phrases: avoid overusing "此外/然而/因此"; prefer simple sentences and line breaks
- Break formula: do not force "三段式"; 2 points is fine; mix short + long sentences
- Avoid dash spam: do not stack "——"
- Prefer concrete details over empty conclusions; if you are unsure, say it plainly and briefly
Thread constraints:
- Each tweet must be <= 280 characters
- Do not make every tweet identical in structure; allow 1-2 short "pause" lines
Step 5: Critic (Self-evaluation) + Rewrite Once
Score the humanized Variant A/B as the target reader (0-10):
- Hook strength
- Information density / value
- Clarity and readability
- Credibility (no exaggeration / no made-up facts)
- Persona fit
- Action likelihood: like / repost / bookmark / reply
- "AI 味" control: no empty grand statements, no templated endings, no vague authority
Rules:
- If both Variant A and B score < 7, rewrite once (produce A2/B2), then run the same humanize pass again, and re-score.
- Select the best variant as final output, but still show both drafts.
Output Format
# 推文创作
## 选题
{topic}
## 推文类型
{short_tweet/thread/reply}
## 风格
{post_style}
---
## Drafts
### Variant A
{For short tweet: single tweet content}
{For thread:}
### 1/N
{first tweet}
### 2/N
{second tweet}
...
### N/N
{final tweet with call to action}
**Critic score (0-10)**: {critic_score_a}
### Variant B
{For short tweet: single tweet content}
{For thread:}
### 1/N
{first tweet}
### 2/N
{second tweet}
...
### N/N
{final tweet with call to action}
**Critic score (0-10)**: {critic_score_b}
---
## Selected
Selected variant: {A|B|A2|B2}
Reason: {one-sentence reason}
---
## 发布建议
- 最佳发布时间: {suggestion}
- 配图建议: {image suggestion if applicable}
- 预期互动: {engagement prediction}
下一步:运行 /x-publish 发布到草稿箱Append machine-readable blocks for hooks/state ingestion:
CREATE_JSON
{
"schema_version": "x_skills.create.v1",
"topic": "{topic}",
"post_type": "short|thread|reply",
"post_style": "high-value|sharp-opinion|trending-comment|story-insight|tech-analysis",
"variants": [
{"id":"A","critic_score_0_10":0,"text":"..."},
{"id":"B","critic_score_0_10":0,"text":"..."}
],
"selected": "A|B|A2|B2",
"rewrite_once": true
}HOOKS_JSON
{
"schema_version": "x_skills.hooks.v1",
"topic": "{topic}",
"hooks": [
{"text":"...","source":"variant.A","tags":["数字|反常识|痛点|悬念|类比"],"score_0_10":0}
]
}Template Priority
1. User templates first: Check assets/templates/{type}/ 2. Default patterns: Use references/post-patterns.md
Example:
Creating 高价值干货 post:
1. Check assets/templates/high-value/
2. If files exist → Learn style from examples
3. If empty → Use default pattern from post-patterns.mdResources
references/user-profile.md
User customization info (shared across all x-skills)
references/post-patterns.md
Default viral post patterns for 5 categories
assets/templates/
User-provided reference posts organized by type:
high-value/- 高价值干货类参考sharp-opinion/- 犀利观点类参考trending-comment/- 热点评论类参考story-insight/- 故事洞察类参考tech-analysis/- 技术解析类参考
Example
User: /x-create Claude 4.5 Opus发布 --type thread
1. Read user-profile.md → persona: 专业严肃、犀利观点 2. Check assets/templates/tech-analysis/ → empty 3. Read post-patterns.md → Get tech-analysis pattern 4. Generate thread:
### 1/5
Claude 4.5 Opus 上线了。我先说结论:它更像“慢一点,但更稳”的那类模型。
我用 3 个小任务试了下,写个线程记录👇
### 2/5
我最直观的感受不是“更聪明”,而是更会停下来检查自己。
同一个问题,它更少给“听起来对”的答案。
### 3/5
三个场景(都不算大项目):
1) 重构一个旧模块:更愿意先问清边界,再动手改
2) 复杂推理题:会把关键假设写出来(这点很救命)
3) 长文档梳理:更少漏掉前后矛盾的地方
### 4/5
代价也很现实:
- 反应慢一点
- 成本可能更高(看你用的套餐/调用方式)
- 你得给它更明确的上下文
### 5/5
如果你做的是“错一次就很麻烦”的任务(代码、决策、长文整理),值得试。
只是日常闲聊,感知没那么强。你们试过了吗?Integration
After creation, suggest:
推文创作完成!
- 类型: {thread/short/reply}
- 字数: {word_count}
- 预计阅读: {read_time}
下一步:运行 /x-publish 发布到X草稿箱
(反馈闭环,可选)
- 采纳并进入正样本:
python ~/.claude/skills/x-create/scripts/x_state.py like --topic-json '{"title":"{topic}","selected":"{A|B}","critic_score":8}'
- 否决并进入负样本:
python ~/.claude/skills/x-create/scripts/x_state.py reject --topic-json '{"title":"{topic}","reason":"low_value"}'
- 写入事件(hooks 自动收集也可用):
python ~/.claude/skills/x-create/scripts/x_state.py event --event create.generated --payload-json '{"topic":"{topic}","variants":["A","B"],"selected":"{A|B}"}'参考推文模板
将你喜欢的爆款推文放入对应分类目录中,x-create会优先参考这些风格。
目录结构
templates/
├── high-value/ # 高价值干货类
├── sharp-opinion/ # 犀利观点类
├── trending-comment/ # 热点评论类
├── story-insight/ # 故事洞察类
└── tech-analysis/ # 技术解析类使用方法
1. 找到你喜欢的爆款推文 2. 复制推文内容到对应分类目录 3. 保存为 .md 或 .txt 文件 4. 运行 /x-create 时会自动学习这些风格
文件格式建议
# 推文标题/主题(可选)
原文内容...
---
来源: @账号名
互动数据: xxx likes, xxx retweets(可选)
我喜欢的点: 简述为什么收藏这条(可选)
# 可选元数据(用于意图路由 / hook 学习 / 去重)
intent: 收藏/转发/回复/点击(可选)
topic_cluster: AI工具/AI创业/MCP协议...(可选)
hook: 你认为最强的开头一句(可选)
tags: 数字, 反常识, 痛点, 悬念, 类比(可选)示例
high-value/ai-tools-list.md:
# AI工具推荐清单
我用了3个月测试了50个AI工具,只有这5个值得付费:
1. Cursor - 代码助手天花板
2. Perplexity - 搜索引擎替代
3. Notion AI - 写作+整理
4. Midjourney - 图像生成
5. Claude Pro - 深度对话
每个都能帮你省下几十小时。
---
来源: @某大V
我喜欢的点: 数字开头,清单结构,有收藏价值注意事项
- 每个目录放3-5条参考即可,不需要太多
- 选择你真正喜欢的风格,而非仅看数据
- 可以混合不同账号的风格
- 如果目录为空,会使用
references/post-patterns.md中的默认模式
爆款推文模式
5类推文的默认创作prompt,当用户没有提供参考推文时使用。
1. 高价值干货类 (high-value)
特点
- 信息密度高,可直接收藏使用
- 提供可行动的建议或工具
- 清单式、步骤式结构
Intent cues(何时路由到该模式)
- 目标动作偏向:收藏 / 转发 / 私信询问
- 主题形态:清单、教程、方法论、工具推荐、工作流
- 表达要求:结论先行 + 强结构 + 可执行
模式
开头引入(选其一)
- 数字引入:"我用了3个月测试了50个AI工具,只有这5个值得付费"
- 问题引入:"为什么你的Prompt总是不好用?因为你犯了这3个错误"
- 痛点引入:"每天花2小时整理信息?这个工作流帮你省下80%的时间"
正文结构
1. 核心观点/结论(先给答案)
2. 具体清单/步骤(信息密度高)
3. 每个要点配简短说明
4. 可选:个人使用心得结尾收尾
- 总结一句话核心价值
- 行动召唤:收藏/转发/评论问问题
- 预告下一篇内容
短推文示例
用 AI 写代码,很多人其实是在做无效 prompt。
正确姿势:
1. 先描述你要什么(目标)
2. 再说你有什么(上下文)
3. 最后加约束(格式/语言/风格)
这个顺序,效率会好很多。Thread示例
### 1/5
我测试了2024年所有主流AI编程助手。
结论可能和你想的不一样:
- Cursor不是最强的
- Copilot不是性价比最高的
- 最该买的是一个你没听过的
一个线程,帮你省下几百刀的试错成本👇
### 2/5
先说结论:
- 最强:Cursor + Claude 4.5
- 性价比:Windsurf
- 特定场景:Codeium(免费+企业级安全)
下面逐个分析...---
2. 犀利观点类 (sharp-opinion)
特点
- 有明确立场和态度
- 反常识或有争议
- 引发讨论和对立
Intent cues(何时路由到该模式)
- 目标动作偏向:回复 / 争论 / 引战式互动(可控)
- 主题形态:行业评论、反常识观点、趋势判断
- 表达要求:强观点 + 论据 + 预设反驳
模式
开头引入(选其一)
- 反常识:"99%的人都在错误地使用ChatGPT"
- 对比冲突:"大家都在追Claude 4,我却在用Claude 3.5"
- 直接否定:"XX根本不值得学,原因如下"
正文结构
1. 抛出观点(越尖锐越好)
2. 1-2个有力论据
3. 回应可能的反驳
4. 重申立场结尾收尾
- 强观点收尾,不软化
- 邀请讨论/反驳
- 可适当挑衅
短推文示例
说句得罪人的:
如果你还在手动写代码,2025年会被淘汰。
不是危言耸听。
AI写代码的效率已经是人类的10倍。
程序员的价值正在从"写代码"转向"定义问题"。
不接受反驳。Thread示例
### 1/4
一个不受欢迎的观点:
大部分人不应该创业。
不是能力问题,是概率问题。
### 2/4
创业成功率不到5%。
这意味着95%的人会失败。
但每个创业者都觉得自己会是那5%。
这就是幸存者偏差。
### 3/4
更残酷的事实:
- 创业失败的代价是3-5年时间
- 这期间你的同学在大厂晋升到P7
- 你的创业经历在HR眼里是"gap"
### 4/4
我的建议:
想创业?先问自己:
1. 如果失败了我能承受吗?
2. 我有不可替代的优势吗?
3. 我是真想创业还是不想上班?
想清楚再说。---
3. 热点评论类 (trending-comment)
特点
- 快速反应,蹭热点
- 独特角度解读
- 预留讨论空间
Intent cues(何时路由到该模式)
- 目标动作偏向:曝光 / 点击 / 及时讨论
- 主题形态:刚发生的新闻、事件、发布、争议
- 表达要求:先给事实一句话 + 你的角度 + 影响/后续预测
模式
开头引入
- 直接切入热点:"XX刚宣布了..."
- 个人角度:"看到XX的新闻,我的第一反应是..."
正文结构
1. 简述热点事实(1句话)
2. 你的独特解读
3. 这件事的深层意义
4. 对普通人的影响结尾收尾
- 开放式问题
- 邀请其他视角
- 预测后续发展
短推文示例
OpenAI刚裁员了。
有意思的是:
- 裁的是非核心部门
- 研究团队一个没动
- 同时还在疯狂招人
这说明什么?
他们在All in下一代模型。GPT-5可能比我们想的更近。回复示例
补充一个视角:
这次裁员的时间点很微妙——正好在Claude 4.5发布后一周。
可能不是巧合。竞争压力下,必须集中资源。---
4. 故事洞察类 (story-insight)
特点
- 个人经历+深层洞察
- 具体场景开头
- 转折+金句收尾
Intent cues(何时路由到该模式)
- 目标动作偏向:关注 / 私信 / 共鸣回复
- 主题形态:复盘、经历、案例、失败教训
- 表达要求:画面感 + 转折 + 可迁移的结论
模式
开头引入
- 具体场景:"昨天和一个10年老友吃饭..."
- 时间线:"三年前,我做了一个决定..."
正文结构
1. 具体场景/故事(要有画面感)
2. 转折/冲突点
3. 我的思考/领悟
4. 提炼出的普适道理结尾收尾
- 金句总结
- 留白,让读者自己思考
短推文示例
昨天面试了一个候选人。
简历完美:大厂背景、名校毕业、项目经验丰富。
但问到"你最大的失败"时,他说:"我没有失败过。"
当场就pass了。
能力可以培养,但不能面对失败的人,走不远。Thread示例
### 1/4
三年前,我月薪3万,觉得人生到达了巅峰。
三年后,我月入30万,却觉得自己是个loser。
中间发生了什么?一个线程👇
### 2/4
转折点是一次聚会。
一个认识不久的朋友说他刚融了500万。
我当时的反应是:凭什么?他能力明明不如我。
那天晚上我失眠了。
### 3/4
后来我想明白了:
我一直在用"能力"定义自己的价值。
但商业世界的规则是:你创造的价值 = 你能赚的钱。
能力强 ≠ 价值大
### 4/4
现在的我:
月入30万,但不觉得自己成功。
因为我知道,这只是起点。
比我强的人太多了。焦虑是常态。
但至少,我不再用月薪定义自己了。---
5. 技术解析类 (tech-analysis)
特点
- 深度技术内容
- 原理拆解,而非使用教程
- 专业但不晦涩
Intent cues(何时路由到该模式)
- 目标动作偏向:收藏 / 关注(建立专业度)
- 主题形态:原理讲解、机制拆解、架构分析、源码分析
- 表达要求:类比+分层解释+实际影响+实操建议
模式
开头引入
- 问题/现象:"为什么Claude的代码能力突然变强了?"
- 类比:"如果把LLM比作人脑,那MCP就是..."
正文结构
1. 问题/现象引入
2. 原理拆解(图解/类比)
3. 实际影响/应用
4. 个人实操建议结尾收尾
- 总结核心takeaway
- 推荐延伸阅读
- 预告深度内容
短推文示例
很多人问:Claude 4.5的extended thinking到底是什么?
简单说:就是让AI"想一想"再回答。
技术上:
1. 模型生成一段"思考过程"
2. 但这段内容不输出给用户
3. 只有最终结论可见
效果:复杂任务通常更稳,但会慢一点
代价:通常会更慢
适合高价值决策场景。Thread示例
### 1/5
MCP协议发布一个月了。
作为第一批吃螃蟹的人,我来讲讲它到底解决了什么问题。
技术帖,但保证你看得懂👇
### 2/5
一句话总结:
MCP = 让AI用工具的标准协议
之前的问题:
- 每个AI都有自己的工具调用格式
- 开发者要给每个AI写适配
- 工具不能跨AI复用
### 3/5
MCP的解决方案:
定义了一套标准:
- 工具怎么描述(Schema)
- 怎么调用(Request/Response)
- 怎么授权(Auth)
一次开发,所有支持MCP的AI都能用。
### 4/5
实际体验:
我给Claude接了一个本地文件系统的MCP Server。
现在它可以直接读写我电脑上的文件。
效率提升不是一点半点。
### 5/5
我的判断:
MCP不是噱头,是真正的基础设施。
我猜接下来会有更多工具围绕 MCP 做兼容。
现在了解一下,至少不容易被术语吓到。
入门资源:[链接]---
通用技巧
Hook公式
1. 数字:具体数字增加可信度 2. 反常识:打破认知引发好奇 3. 痛点:直击读者困扰 4. 悬念:制造信息差
节奏控制
- 短句为主,长句点缀
- 一行不超过25个字
- 重要信息单独成段
- 善用空行制造停顿
去 AI 味(Humanizer-zh 快速清单)
- 先删:开场白/结尾套话/客套("当然"、"希望这对你有帮助"、"让我们深入探讨")
- 词汇避雷:此外、至关重要、关键时刻、不断演变的格局、彰显、赋能、致力于、令人叹为观止
- 句式避雷:"这不仅仅是…而是…"、"不仅…而且…"、"尽管…仍…(挑战与未来展望)"
- 归因要具体:不要"专家认为/行业报告显示"这类空话;要么给具体来源,要么改成"我观察到/我的判断是"
- 打破模板:不要硬凑三点;两点就够;句子长短交错
- 用具体替代空话:能给数字/场景/例子就给;不能就别拔高
- 标点克制:少用"——"、少用连续感叹号
结尾模式
- 行动召唤:转发/评论/收藏
- 开放问题:你怎么看?
- 悬念预告:下一篇讲...
- 金句收尾:一句话总结
表情使用
- 专业严肃风格:少用或不用
- 轻松幽默风格:适当使用
- 关键位置:👇指向下文、🧵Thread标识
---
Critic Rubric(自检评分清单,0-10)
用于 x-create 的 A/B 变体自检与重写决策:
1. Hook 强度:前 1-2 句是否制造信息差/反常识/具体数字/痛点? 2. 信息密度:是否每 1-2 行都有“新信息”?是否可收藏? 3. 可读性:短句、换行、节奏是否顺畅?是否有冗余? 4. 可信度:是否有来源/数据/例子支撑?是否夸大?是否在编数据? 5. 账号匹配:是否符合 persona 与目标受众语言? 6. 行动导向:是否自然引导用户:收藏/转发/评论? 7. AI 味控制:是否出现宏大空话、宣传腔、模板连接词堆叠、三段式强行、"挑战与未来展望"式收尾?
建议阈值:
>= 7:可发布< 7:重写一次(更强 hook / 更清晰结构 / 更具体证据 / 去掉 AI 味套话)
X-Skills 用户配置
首次使用时通过问答自动填写,后续自动读取。
初始化状态
initialized: false账号定位
account:
domains: [] # 领域:AI/科技, 创业/商业, 个人成长, 投资理财
target_audience: "" # 目标受众:中文用户, 英文用户, 双语用户
persona_style: "" # 人设风格:专业严肃, 轻松幽默, 犀利观点, 温暖亲和
language: "zh-CN" # 创作语言打分权重(可自定义)
scoring:
trending: 4 # 热度/趋势权重(满分4)
controversy: 2 # 争议性权重(满分2)
value: 3 # 高价值权重(满分3)
relevance: 1 # 账号相关性权重(满分1)
threshold: 7 # 进入创作池的分数阈值实验与默认行为(可选)
用于控制 x-create 的默认创作行为(A/B、critic、重写等):
experiments:
ab_variants: true # 默认生成 Variant A/B
critic_enabled: true # 启用自检打分
critic_threshold: 7 # 低于阈值触发重写
auto_rewrite_once: true # 最多自动重写一次使用说明
1. 首次使用:运行任意x-skills时,会自动进行问答收集信息 2. 修改配置:直接编辑此文件中的yaml值 3. 重置配置:将 initialized 改为 false,下次使用时重新问答
配置示例
已初始化的用户配置示例:
initialized: true
account:
domains:
- AI/科技
- 创业
- 个人成长
target_audience: "中文用户"
persona_style: "专业严肃、犀利观点、偶尔小幽默"
language: "zh-CN"
scoring:
trending: 4
controversy: 2
value: 3
relevance: 1
threshold: 7"""Hook extraction helpers.
This module helps extract and normalize "hooks" (strong opening lines) from generated text.
Standard library only.
"""
from __future__ import annotations
import re
from typing import Dict, List, Sequence
from similarity import normalize_text
_THREAD_HEADER_RE = re.compile(r"^###\s*\d+\/\d+\s*$", re.MULTILINE)
def split_sentences(text: str) -> List[str]:
# Very lightweight sentence-ish split for zh/en
parts = re.split(r"[\n。!?!?]+", text)
out = [p.strip() for p in parts if p.strip()]
return out
def extract_hook_candidates(text: str, max_candidates: int = 5) -> List[str]:
"""Extract 1-2 opening sentences for short tweets or the first tweet of a thread."""
if not text:
return []
# If it's a thread, try to isolate first tweet block
m = _THREAD_HEADER_RE.search(text)
if m:
start = m.end()
# find next header
m2 = _THREAD_HEADER_RE.search(text, start)
first_block = text[start : m2.start()] if m2 else text[start:]
base = first_block.strip()
else:
base = text.strip()
sentences = split_sentences(base)
if not sentences:
return []
candidates: List[str] = []
# Take first 1-2 sentences; also consider first line
first_line = base.splitlines()[0].strip() if base.splitlines() else ""
for s in [first_line] + sentences[:2]:
s = s.strip()
if not s:
continue
if len(s) < 8:
continue
if len(s) > 120:
s = s[:120].rstrip()
candidates.append(s)
# Dedup by normalized form
seen = set()
deduped: List[str] = []
for c in candidates:
k = normalize_text(c)
if k in seen:
continue
seen.add(k)
deduped.append(c)
return deduped[:max_candidates]
def guess_tags(hook: str) -> List[str]:
"""Heuristic tags for hooks (optional)."""
tags: List[str] = []
if re.search(r"\d", hook):
tags.append("数字")
if any(x in hook for x in ["99%", "90%", "没人", "大多数", "真相", "其实"]):
tags.append("反常识")
if any(x in hook for x in ["痛点", "困扰", "花了", "浪费", "效率"]):
tags.append("痛点")
if any(x in hook for x in ["为什么", "如何", "你知道吗", "结果是"]):
tags.append("悬念")
if any(x in hook for x in ["就像", "好比", "如果把"]):
tags.append("类比")
return tags
def hooks_json(topic: str, hooks: Sequence[str]) -> Dict:
return {
"schema_version": "x_skills.hooks.v1",
"topic": topic,
"hooks": [
{"text": h, "source": "extracted", "tags": guess_tags(h), "score_0_10": None}
for h in hooks
],
}
"""Minimal schema helpers.
No external dependencies. Only validates required top-level keys and schema_version.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional
@dataclass
class ValidationError(Exception):
message: str
def __str__(self) -> str:
return self.message
def require_keys(obj: Dict[str, Any], keys: Iterable[str], context: str = "") -> None:
missing: List[str] = [k for k in keys if k not in obj]
if missing:
prefix = f"{context}: " if context else ""
raise ValidationError(prefix + f"Missing keys: {', '.join(missing)}")
def require_schema(obj: Dict[str, Any], expected_prefix: str, context: str = "") -> None:
v = obj.get("schema_version")
if not isinstance(v, str) or not v.startswith(expected_prefix):
prefix = f"{context}: " if context else ""
raise ValidationError(prefix + f"Invalid schema_version: {v!r} (expected prefix {expected_prefix!r})")
"""Lightweight similarity utilities (no embeddings).
Goal: provide explainable similarity in [0,1] for Chinese/English mixed text.
Standard library only.
"""
from __future__ import annotations
import re
import string
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Iterable, List, Sequence, Set, Tuple
_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
_WS_RE = re.compile(r"\s+")
_WORD_RE = re.compile(r"[a-z0-9]+", re.IGNORECASE)
def normalize_text(text: str) -> str:
if text is None:
return ""
t = text.strip().lower()
t = _URL_RE.sub(" ", t)
# Replace punctuation with spaces (keep CJK chars)
punct = string.punctuation
trans = str.maketrans({c: " " for c in punct})
t = t.translate(trans)
t = _WS_RE.sub(" ", t).strip()
return t
def char_ngrams(text: str, n: int) -> Set[str]:
t = normalize_text(text)
t = t.replace(" ", "")
if len(t) < n:
return set()
return {t[i : i + n] for i in range(0, len(t) - n + 1)}
def word_tokens(text: str) -> Set[str]:
t = normalize_text(text)
return set(_WORD_RE.findall(t))
def jaccard(a: Set[str], b: Set[str]) -> float:
if not a and not b:
return 0.0
if not a or not b:
return 0.0
inter = len(a.intersection(b))
union = len(a.union(b))
return inter / union if union else 0.0
def similarity(a_text: str, b_text: str) -> float:
"""Return similarity score in [0,1]."""
a_norm = normalize_text(a_text)
b_norm = normalize_text(b_text)
# Mixed strategy:
# - char 2/3-gram for short & CJK text
# - word tokens for English
# - sequence ratio for overall string closeness
c2 = jaccard(char_ngrams(a_norm, 2), char_ngrams(b_norm, 2))
c3 = jaccard(char_ngrams(a_norm, 3), char_ngrams(b_norm, 3))
wj = jaccard(word_tokens(a_norm), word_tokens(b_norm))
seq = SequenceMatcher(None, a_norm, b_norm).ratio() if a_norm and b_norm else 0.0
score = 0.30 * c2 + 0.35 * c3 + 0.15 * wj + 0.20 * seq
# guard
if score < 0:
return 0.0
if score > 1:
return 1.0
return score
@dataclass(frozen=True)
class SimilarityHit:
id: str
title: str
score: float
def topk_similarity(query: str, corpus: Sequence[Tuple[str, str]], topk: int = 5) -> List[SimilarityHit]:
"""corpus items are (id, title)."""
hits: List[SimilarityHit] = []
for cid, title in corpus:
s = similarity(query, title)
hits.append(SimilarityHit(id=cid, title=title, score=s))
hits.sort(key=lambda h: h.score, reverse=True)
return hits[: max(1, topk)]
"""x_state.py - Minimal state manager for x-skills feedback loop.
Designed to run from: ~/.claude/skills/x-create/scripts/x_state.py
Standard library only.
State directory default: ~/.claude/skills/x-create/state/
Files:
- liked_topics.json
- rejected_topics.json
- events.jsonl
This script is intentionally lightweight: it stores what happened, not how LLM produced it.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from similarity import normalize_text, topk_similarity
SCHEMA_LIKED = "x_skills.liked_topics.v1"
SCHEMA_REJECTED = "x_skills.rejected_topics.v1"
SCHEMA_EVENT = "x_skills.event.v1"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def default_state_dir() -> Path:
return Path(os.path.expanduser("~/.claude/skills/x-create/state"))
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
ensure_dir(path.parent)
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, path)
finally:
try:
if os.path.exists(tmp):
os.unlink(tmp)
except Exception:
pass
def read_json_or_default(path: Path, default: Dict[str, Any]) -> Dict[str, Any]:
if not path.exists():
return default
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def append_jsonl(path: Path, obj: Dict[str, Any]) -> None:
ensure_dir(path.parent)
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(obj, ensure_ascii=False))
f.write("\n")
def make_id(prefix: str, text: str) -> str:
norm = normalize_text(text)
h = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:8]
return f"{prefix}_{h}"
def state_paths(state_dir: Path) -> Tuple[Path, Path, Path]:
return (
state_dir / "liked_topics.json",
state_dir / "rejected_topics.json",
state_dir / "events.jsonl",
)
def cmd_init(args: argparse.Namespace) -> int:
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
liked_path, rejected_path, events_path = state_paths(state_dir)
ensure_dir(state_dir)
liked = read_json_or_default(
liked_path, {"schema_version": SCHEMA_LIKED, "updated_at": utc_now(), "items": []}
)
rejected = read_json_or_default(
rejected_path,
{"schema_version": SCHEMA_REJECTED, "updated_at": utc_now(), "items": []},
)
# Ensure schema keys exist
if "schema_version" not in liked:
liked["schema_version"] = SCHEMA_LIKED
if "items" not in liked:
liked["items"] = []
liked["updated_at"] = utc_now()
if "schema_version" not in rejected:
rejected["schema_version"] = SCHEMA_REJECTED
if "items" not in rejected:
rejected["items"] = []
rejected["updated_at"] = utc_now()
atomic_write_json(liked_path, liked)
atomic_write_json(rejected_path, rejected)
# Touch events file
ensure_dir(events_path.parent)
if not events_path.exists():
events_path.write_text("", encoding="utf-8")
print(json.dumps({"ok": True, "state_dir": str(state_dir)}, ensure_ascii=False))
return 0
def _append_event(state_dir: Path, event: str, payload: Dict[str, Any], run_id: Optional[str]) -> str:
_, _, events_path = state_paths(state_dir)
rid = run_id or make_id("run", utc_now())
evt = {
"schema_version": SCHEMA_EVENT,
"ts": utc_now(),
"run_id": rid,
"event": event,
"payload": payload,
}
append_jsonl(events_path, evt)
return rid
def cmd_event(args: argparse.Namespace) -> int:
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
payload = json.loads(args.payload_json) if args.payload_json else {}
rid = _append_event(state_dir=state_dir, event=args.event, payload=payload, run_id=args.run_id)
print(json.dumps({"ok": True, "event": args.event, "run_id": rid}, ensure_ascii=False))
return 0
def _upsert_item(items: List[Dict[str, Any]], item: Dict[str, Any], key: str = "id") -> None:
for i, it in enumerate(items):
if it.get(key) == item.get(key):
items[i] = item
return
items.append(item)
def cmd_like(args: argparse.Namespace) -> int:
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
liked_path, _, _ = state_paths(state_dir)
topic = json.loads(args.topic_json)
title = topic.get("title") or topic.get("topic") or ""
if not title:
raise SystemExit("topic_json must include 'title' or 'topic'")
tid = topic.get("id") or make_id("topic", title)
now = utc_now()
liked = read_json_or_default(
liked_path, {"schema_version": SCHEMA_LIKED, "updated_at": now, "items": []}
)
items = liked.get("items", [])
item = {
"id": tid,
"created_at": topic.get("created_at") or now,
"updated_at": now,
"title": title,
"norm": normalize_text(title),
"language": topic.get("language") or "zh-CN",
"signals": {
"adopted": True,
"posted": bool(topic.get("posted", False)),
"manual_rating_0_10": topic.get("rating") or topic.get("manual_rating_0_10"),
"reason": topic.get("reason"),
},
"creation": {
"post_type": topic.get("post_type"),
"post_style": topic.get("post_style"),
"selected_variant": topic.get("selected") or topic.get("selected_variant"),
"critic_score": topic.get("critic_score") or topic.get("critic_score_0_10"),
},
"sources": topic.get("sources", []),
"keywords": topic.get("keywords", []),
"entities": topic.get("entities", []),
"hooks": topic.get("hooks", []),
}
_upsert_item(items, item)
liked["items"] = items
liked["updated_at"] = now
atomic_write_json(liked_path, liked)
# also append event (silent)
_append_event(
state_dir=state_dir,
event="feedback.adopted",
payload={"topic_id": tid, "title": title},
run_id=args.run_id,
)
print(json.dumps({"ok": True, "topic_id": tid}, ensure_ascii=False))
return 0
def cmd_reject(args: argparse.Namespace) -> int:
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
_, rejected_path, _ = state_paths(state_dir)
topic = json.loads(args.topic_json)
title = topic.get("title") or topic.get("topic") or ""
if not title:
raise SystemExit("topic_json must include 'title' or 'topic'")
rid = topic.get("id") or make_id("rej", title)
now = utc_now()
rejected = read_json_or_default(
rejected_path,
{"schema_version": SCHEMA_REJECTED, "updated_at": now, "items": []},
)
items = rejected.get("items", [])
item = {
"id": rid,
"created_at": topic.get("created_at") or now,
"updated_at": now,
"title": title,
"norm": normalize_text(title),
"language": topic.get("language") or "zh-CN",
"stage": topic.get("stage") or "filter",
"reason": topic.get("reason") or "rejected",
"metadata": topic.get("metadata", {}),
}
_upsert_item(items, item)
rejected["items"] = items
rejected["updated_at"] = now
atomic_write_json(rejected_path, rejected)
# also append event (silent)
_append_event(
state_dir=state_dir,
event="feedback.rejected",
payload={"rejected_id": rid, "title": title},
run_id=args.run_id,
)
print(json.dumps({"ok": True, "rejected_id": rid}, ensure_ascii=False))
return 0
def cmd_similarity(args: argparse.Namespace) -> int:
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
_, rejected_path, _ = state_paths(state_dir)
against = args.against
if against != "rejected":
raise SystemExit("--against currently only supports 'rejected'")
rejected = read_json_or_default(
rejected_path,
{"schema_version": SCHEMA_REJECTED, "updated_at": utc_now(), "items": []},
)
corpus = [(it.get("id", ""), it.get("title", "")) for it in rejected.get("items", [])]
hits = topk_similarity(args.text, corpus=corpus, topk=args.topk)
out = {
"query": args.text,
"max_similarity": hits[0].score if hits else 0.0,
"matched": [{"id": h.id, "title": h.title, "score": h.score} for h in hits],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
def cmd_hook_ingest(args: argparse.Namespace) -> int:
"""Ingest HOOKS_JSON into liked_topics.json.
Expected hooks-json shape:
{
"schema_version": "x_skills.hooks.v1",
"topic": "..." | "topic_id": "topic_xxx",
"hooks": [{"text":"...", ...}]
}
"""
state_dir = Path(args.state_dir) if args.state_dir else default_state_dir()
liked_path, _, _ = state_paths(state_dir)
payload = json.loads(args.hooks_json)
topic_id = payload.get("topic_id")
topic_title = payload.get("topic")
hooks = payload.get("hooks", [])
if not hooks:
raise SystemExit("hooks_json must include non-empty 'hooks'")
liked = read_json_or_default(
liked_path,
{"schema_version": SCHEMA_LIKED, "updated_at": utc_now(), "items": []},
)
items = liked.get("items", [])
target = None
if topic_id:
for it in items:
if it.get("id") == topic_id:
target = it
break
if target is None and topic_title:
tnorm = normalize_text(topic_title)
for it in items:
if it.get("norm") == tnorm:
target = it
break
if target is None:
raise SystemExit("No matching liked topic found. Provide topic_id or ensure topic title matches an existing liked entry.")
existing = target.get("hooks", [])
# de-dup by normalized text
seen = {normalize_text(h.get("text", "")) for h in existing if isinstance(h, dict)}
for h in hooks:
if not isinstance(h, dict):
continue
t = h.get("text", "")
if not t:
continue
k = normalize_text(t)
if k in seen:
continue
seen.add(k)
existing.append(h)
target["hooks"] = existing
target["updated_at"] = utc_now()
liked["items"] = items
liked["updated_at"] = utc_now()
atomic_write_json(liked_path, liked)
_append_event(
state_dir=state_dir,
event="hook.ingested",
payload={"topic_id": target.get("id"), "hooks_added": len(hooks)},
run_id=args.run_id,
)
print(json.dumps({"ok": True, "topic_id": target.get("id"), "hooks_total": len(existing)}, ensure_ascii=False))
return 0
def _add_common(subp: argparse.ArgumentParser) -> None:
# Support placing these options BEFORE or AFTER subcommand.
subp.add_argument(
"--state-dir",
default=None,
help="Override state dir (default ~/.claude/skills/x-create/state)",
)
subp.add_argument("--run-id", default=None, help="Optional run id for event correlation")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="x_state.py")
_add_common(p)
sub = p.add_subparsers(dest="cmd", required=True)
sub_init = sub.add_parser("init", help="Initialize state files")
_add_common(sub_init)
sub_init.set_defaults(func=cmd_init)
sub_event = sub.add_parser("event", help="Append an event to events.jsonl")
_add_common(sub_event)
sub_event.add_argument("--event", required=True)
sub_event.add_argument("--payload-json", default="{}")
sub_event.set_defaults(func=cmd_event)
sub_like = sub.add_parser("like", help="Upsert a liked topic")
_add_common(sub_like)
sub_like.add_argument("--topic-json", required=True)
sub_like.set_defaults(func=cmd_like)
sub_reject = sub.add_parser("reject", help="Upsert a rejected topic")
_add_common(sub_reject)
sub_reject.add_argument("--topic-json", required=True)
sub_reject.set_defaults(func=cmd_reject)
sub_sim = sub.add_parser("similarity", help="Compute similarity against a corpus")
_add_common(sub_sim)
sub_sim.add_argument("--against", required=True, choices=["rejected"])
sub_sim.add_argument("--text", required=True)
sub_sim.add_argument("--topk", type=int, default=3)
sub_sim.set_defaults(func=cmd_similarity)
sub_hook = sub.add_parser("hook-ingest", help="Ingest HOOKS_JSON into liked topics")
_add_common(sub_hook)
sub_hook.add_argument("--hooks-json", required=True)
sub_hook.set_defaults(func=cmd_hook_ingest)
return p
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())