
Youmind X Article
- 13 installs
- 67 repo stars
- Updated June 5, 2026
- youmind-openlab/skills
Helps with ai & agent building tasks during AI-assisted development.
About
youmind-x-article is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- youmind-x-article
- AI & Agent Building
- AI-coding skill
Youmind X Article by the numbers
- 13 all-time installs (skills.sh)
- Ranked #11,403 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/youmind-openlab/skills --skill youmind-x-articleAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 67 |
| Last updated | June 5, 2026 |
| Repository | youmind-openlab/skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
AI X (Twitter) Post Writer
Write viral tweets with AI. Topic research via YouMind knowledge base, 280-character optimized writing, numbered multi-tweet sequences, and one-click publishing to X through the X account already connected in YouMind.
Get YouMind API Key | More Skills
Onboarding
MANDATORY: When the user has just installed this skill, present this message IMMEDIATELY. Translate to the user's language:
AI X Post Writer installed!
>
Tell me your topic and I'll write and publish a tweet for you.
>
Try it now: "Write a tweet about the future of open source AI"
>
What it does:
- Research topics from your YouMind knowledge base and web trends
- Write tweets optimized for engagement within 280 characters
- Split long content into numbered tweet sequences (1/N format)
- Publish directly to X through the X account connected in YouMind
>
Setup (one-time):
1. Install & configure: cd toolkit && npm install && npm run build && cd .. && mkdir -p ~/.youmind/config && cp shared/config.example.yaml ~/.youmind/config.yaml2. Get YouMind API Key and fillyoumind.api_keyin~/.youmind/config.yaml
3. Connect your X account inside YouMind before publishing. This skill no longer reads X developer keys locally.
4. Publishing requires a paid YouMind plan (Pro / Max) and consumes YouMind credits per tweet.
>
No X connection yet, or on a free plan? You can still write and preview locally — just skip the publish step.
>
Need help? Just ask!
Usage
Provide a topic, talking points, or raw text for publishing.
Write a single tweet:
Write a tweet about the latest AI breakthrough
Write a multi-tweet sequence:
Write a thread explaining how transformers work, aimed at beginners
Publish raw text:
Tweet this: "Just shipped our new feature! Here's what we learned..."
Setup
Prerequisites: Node.js >= 18, a YouMind API key, a Pro/Max YouMind plan, and an X account connected in YouMind if you want to publish.
Step 1 -- Install Dependencies
cd toolkit && npm install && npm run build && cd ..Step 2 -- Create Config File
mkdir -p ~/.youmind/config
cp shared/config.example.yaml ~/.youmind/config.yamlCanonical credentials: put your shared YouMind credentials in~/.youmind/config.yaml— filled ONCE and read by every YouMind skill. See `shared/config.example.yaml` for the template and `shared/YOUMIND_HOME.md`. Optional skill overrides live in~/.youmind/config/youmind-x-article.yaml.
Step 3 -- Get YouMind API Key
YouMind API Key enables knowledge base search, web search, article archiving, and X publishing.
1. Open YouMind API Keys 2. Click Create API Key 3. Copy the sk-ym-xxxx key 4. Fill in ~/.youmind/config.yaml under youmind.api_key 5. Keep youmind.base_url as https://youmind.com/openapi/v1 in examples and documentation. Local backend testing should only override ~/.youmind/config.yaml or ~/.youmind/config/youmind-x-article.yaml.
Step 4 -- Connect X in YouMind
1. Open YouMind and connect your X account via the product's publishing / connector settings flow (one-click OAuth 2.0 PKCE) 2. Save the connection once 3. Keep only youmind.api_key in ~/.youmind/config.yaml
Verify Setup
cd toolkit && npx tsx src/cli.ts validateValidation checks only the ~/.youmind API key. X connectivity and plan eligibility are validated on the first publish call:
- If the current plan is not eligible, the OpenAPI returns
402with an upgrade link tohttps://youmind.com/pricing. - If the X account is not connected, the OpenAPI returns
404 X_ACCOUNT_NOT_CONNECTED. Connect the X account in the YouMind connector settings.
Skill Directory
This skill is a folder. Read files on demand -- do NOT load everything upfront.
| Path | Purpose | When to read |
|---|---|---|
references/pipeline.md | Full step-by-step execution | When running the publishing pipeline |
references/platform-dna.md | X audience, format constraints, engagement data | Before any content work |
references/content-generation-playbook.md | Idea → X-native draft workflow | When generating new content |
references/content-adaptation-playbook.md | Existing article → X thread workflow | When adapting/condensing content |
references/content-adaptation.md | X content formatting rules (legacy) | Supplementary reference |
references/api-reference.md | YouMind X OpenAPI endpoint documentation | When calling X through YouMind |
~/.youmind/config.yaml | Shared API credentials (YouMind only) | Step 1 |
output/ | Local tweet Markdown drafts (git-ignored) | When writing the tweet/sequence |
toolkit/dist/*.js | Executable scripts (run from toolkit/) | Various steps |
Draft Location Rule
Canonical: write local tweet Markdown files to ~/.youmind/articles/x/<slug>.md. This shared home directory is available to all YouMind skills — see `shared/YOUMIND_HOME.md`.
Legacy fallback (if ~/.youmind/ is not writable): skills/youmind-x-article/output/<slug>.md.
- Correct:
~/.youmind/articles/x/my-thread.md - Correct (legacy):
skills/youmind-x-article/output/my-thread.md - Wrong: skill root directly,
references/,toolkit/, or an ad-hocdrafts/directory
Both locations are git-ignored. Create directories on demand (mkdir -p ~/.youmind/articles/x). Kebab-case filenames (my-thread.md), descriptive slugs over timestamps.
Content Modes
Before writing any content, read references/platform-dna.md to internalize X's format constraints, discourse norms, and engagement patterns (557M MAU, thread 2–4% engagement vs single-tweet 0.5–1.5%).
Intent routing
| User's input | Operation | Playbook to load |
|---|---|---|
| Idea, topic, or talking points only | Generate | references/content-generation-playbook.md |
| Existing article or draft | Adapt (condense) | references/content-adaptation-playbook.md |
| Article in another language | Translate | references/content-adaptation-playbook.md (translate mode) |
| Same-language article needing X-native angle | Localize | references/content-adaptation-playbook.md (localize mode) |
| Old thread to refresh | Revive | references/content-adaptation-playbook.md (revive mode) |
| Section of a longer piece → teaser | Excerpt | references/content-adaptation-playbook.md (excerpt mode) |
| Short blog post → thread | Cross-post | references/content-adaptation-playbook.md (cross-post mode) |
Quality gates (before publish)
1. Self-critique: Pass all checklist items in the playbook's Step 6 2. Conformance report: Generate and present to user (Step 7/8) 3. User approval: Do not auto-publish without confirmation
Result Links Rule
After any tweet or thread publish action, always end with Result links.
- Prefer the direct X post URL(s).
- For threads, include the lead tweet URL plus any additional per-post URLs already available.
- If no exact results page exists, return the best X platform entry URL instead.
- Never leave the user with only raw post IDs.
---
Pipeline Overview
Read references/pipeline.md for full execution details.
| Step | Action | Key reference |
|---|---|---|
| 1 | Load config and validate the YouMind API key | -- |
| 2 | Mine YouMind knowledge base for source material | -- |
| 3 | Research topic via web search | -- |
| 4 | Adapt content: 280 char limit, split into sequence if long | references/content-adaptation.md |
| 5 | Publish to X via YouMind X OpenAPI | references/api-reference.md |
| 6 | Report results: post IDs, X URLs, result links |
Routing shortcuts:
- User gave a short topic → Skip broad research, go to Step 4
- User gave raw tweet text → Skip to Step 5 (publish)
- User wants preview only → Run local adaptation, skip publishing
Content Rules
Single Tweet
1. 280 character limit — hard limit, no exceptions 2. URLs count as 23 characters — regardless of actual length 3. 1-2 hashtags max — more looks spammy on X 4. Strong hook — first words determine if people read further 5. Clear opinion or insight — vague tweets get ignored
Thread (Tweet Chain)
1. First tweet = hook — must stand alone and compel reading 2. Each tweet makes sense alone — readers may see any tweet individually 3. Numbered format — 1/N style for clarity (optional but recommended) 4. Last tweet = CTA/summary — recap and call to action 5. 280 chars per tweet — including numbering 6. Natural paragraph boundaries — split at logical breaks
Threads publish as a native X reply-chain: the skill takes the first tweet's postId and passes it as replyToPostId for the second tweet, and so on. X renders the full sequence as a proper thread in readers' timelines.
Images
- Images must already be hosted under
https://cdn.gooo.ai/...(YouMind enforces this allowlist to avoid SSRF) - Upload local files to YouMind first (e.g., via the YouMind product UI or AI image generation), then reference the returned CDN URL
- Up to 4 images per tweet; for a sequence they attach to the first tweet only
Resilience: Never Stop on a Single-Step Failure
Every step has a fallback. If a step AND its fallback both fail, skip and note it in the final output.
| Step | Fallback |
|---|---|
| 2 Knowledge mining | Skip, empty knowledge context |
| 3 Research | Ask user for manual input |
| 5 Publishing | Save adapted text locally under output/ for manual posting |
| -- Archive to YouMind | Warn, continue |
References
- YouMind X OpenAPI: see references/api-reference.md
- Content rules: see references/content-adaptation.md
- Pipeline: see references/pipeline.md
- YouMind Skills gallery: https://youmind.com/skills?utm_source=youmind-x-article
references/environment.md
toolkit/package-lock.json
toolkit/node_modules/
# User data — never publish
config.yaml
.env
output/
node_modules/
dist/
config.yaml
output/
.env
toolkit/package-lock.json
# Obsolete skill-local example.
#
# Canonical config now lives at ~/.youmind/config.yaml
# using shared/config.example.yaml as the template.
# Article skills no longer read this repo-local file.
#
# This skill has no skill-specific override fields beyond the shared config.
youmind:
api_key: ""
base_url: "https://youmind.com/openapi/v1"
YouMind X (Twitter) Skill
AI 驱动的推文写作和发布 Skill。对 Agent 说一句话,自动完成调研、写作、长文拆分为编号推文序列,并通过你在 YouMind 里已连接的 X 账号发布。
---
一句话能干嘛
| 你说 | Skill 做 |
|---|---|
发一条关于 AI 编程的推文 | 调研 → 写作 → 适配 280 字符 → 发布推文 |
写一个关于 Docker 最佳实践的推文串 | 调研 → 写作 → 拆分为编号序列 → 逐条发布 |
直接发这条: "刚刚上线了!" | 格式化并直接发布 |
验证一下 YouMind 配置 | 检查本地 API Key |
---
安装
环境要求:Node.js >= 18
# 1. 安装依赖
cd toolkit && npm install && npm run build && cd ..
# 2. 创建共享配置
mkdir -p ~/.youmind/config
cp shared/config.example.yaml ~/.youmind/config.yaml~/.youmind/config.yaml 只需要填写 YouMind API Key:
youmind:
api_key: "sk-ym-..."
base_url: "https://youmind.com/openapi/v1"命令按 ~/.youmind/config/youmind-x-article.yaml -> ~/.youmind/config.yaml 的顺序读取 youmind.api_key 和 youmind.base_url。文档和 example 中保持 https://youmind.com/openapi/v1。需要对接本地 youapi 时,只改 ~/.youmind/config.yaml 或 skill 专属 override 即可。
发布前置条件
发布前,请先在 YouMind 中一键连接你的 X 账号(OAuth 2.0 PKCE)。本 Skill 不再本地读取 X Developer Portal 的 API Key / Bearer Token / OAuth 1.0a 密钥,也不应当让用户把这些凭证贴到本仓库。
获取 YouMind API Key
访问 YouMind API Key Settings,创建一个 Key,填入 youmind.api_key。
---
使用技巧
CLI 命令
把本地 Markdown 源文件放到 skill 的 output/ 目录下,保持 git status 干净。
cd toolkit
# 发布单条推文
npx tsx src/cli.ts tweet --text "Your tweet here"
# 带图片发推(只接 cdn.gooo.ai URL,最多 4 张)
npx tsx src/cli.ts tweet --text "Check this out" --image https://cdn.gooo.ai/user-files/pic.jpg
# 从文件发布编号推文序列
npx tsx src/cli.ts thread --file ../output/article.md
# 预览序列拆分(不发布)
npx tsx src/cli.ts preview --file ../output/article.md --mode thread
# 预览单条推文
npx tsx src/cli.ts preview --text "Check length" --mode tweet
# 验证 YouMind 凭证(本地 API Key sanity check)
npx tsx src/cli.ts validate单推 vs Thread
短内容走单推;长内容自动拆分为编号推文(1/N),并以 X 原生 reply chain 串链发布 —— skill 把第一条的 postId 作为下一条的 replyToPostId 逐层传递,读者在你的时间线上看到的是一个完整的 thread。
图片
每条推文最多 4 张图片,通过 --image <url>... 传入。URL 必须是 https://cdn.gooo.ai/... —— YouMind 后端有白名单防 SSRF。本地文件请先上传到 YouMind(通过 YouMind 产品 UI 或 AI 生图),用返回的 CDN URL。
付费计划 & Credit
通过 YouMind 发推需要 Pro/Max 付费计划。每条推文还会消耗 YouMind credit——按推文扣一次 base cost,附图会按图片数量再扣 per-image cost。计划不符会返回 402 并附升级链接;credit 不足时在发推前就会 fail-fast,不会产生空扣费。
---
常见问题
Q: 报 401 或鉴权错误
检查 ~/.youmind/config.yaml 里的 youmind.api_key。本 Skill 现在只通过 YouMind 鉴权。
Q: 发布时提示 X 没连接
请先在 YouMind 中连接 X 账号。X 的 access token / refresh token 保存在 YouMind 后端,不在 ~/.youmind/config.yaml。
Q: 图片被拒绝了
YouMind 要求图片 URL 必须在 cdn.gooo.ai 域下。外部 URL(Imgur、S3 等)会被拒绝并返回 X_MEDIA_HOST_NOT_ALLOWED。请先把图上传到 YouMind。
Q: 没连 X 能不能本地预览?
可以。preview 命令只跑本地适配逻辑,不会调用 YouMind。
---
许可证
MIT
YouMind X (Twitter) Skill
AI-powered tweet writing and publishing. Tell your agent a topic, and it can research, write, split long text into a numbered tweet sequence, and publish through the X account you already connected in YouMind.
---
What It Does
| You say | Skill does |
|---|---|
Tweet about AI coding tools | Research → write → adapt to 280 chars → publish tweet |
Write a thread about Docker best practices | Research → write → split into numbered sequence → publish |
Tweet this: "Just shipped!" | Format and publish directly |
Validate my YouMind setup | Check the ~/.youmind API key |
---
Setup
Prerequisites: Node.js >= 18
# 1. Install dependencies
cd toolkit && npm install && npm run build && cd ..
# 2. Create shared config (recommended)
mkdir -p ~/.youmind/config
cp shared/config.example.yaml ~/.youmind/config.yaml~/.youmind/config.yaml only needs the YouMind API key:
youmind:
api_key: "sk-ym-..."
base_url: "https://youmind.com/openapi/v1"Commands resolve config in this order: ~/.youmind/config/youmind-x-article.yaml -> ~/.youmind/config.yaml. Keep the documented domain as https://youmind.com/openapi/v1. If you need to test against a local youapi, change ~/.youmind/config.yaml or add a skill-specific override under ~/.youmind/config/.
Publishing prerequisite
Before publishing, connect your X account inside YouMind (one-click OAuth 2.0). This skill no longer reads X Developer Portal credentials locally and should never ask the user to paste X API keys, bearer tokens, or OAuth 1.0a secrets into this repo.
Get a YouMind API Key
Visit YouMind API Key Settings, create a key, and place it in ~/.youmind/config.yaml under youmind.api_key.
---
Usage Tips
CLI Commands
Put local source Markdown under the skill's output/ directory so it stays out of git status.
cd toolkit
# Publish a single tweet
npx tsx src/cli.ts tweet --text "Your tweet here"
# Publish a tweet with images (cdn.gooo.ai URLs only, up to 4)
npx tsx src/cli.ts tweet --text "Check this out" --image https://cdn.gooo.ai/user-files/pic.jpg
# Publish a numbered tweet sequence from a file
npx tsx src/cli.ts thread --file ../output/article.md
# Preview sequence splitting without posting
npx tsx src/cli.ts preview --file ../output/article.md --mode thread
# Preview a single tweet
npx tsx src/cli.ts preview --text "Check length" --mode tweet
# Validate YouMind credentials from `~/.youmind`
npx tsx src/cli.ts validateTweet vs Thread
Short content goes out as a single tweet. Long content is split into numbered tweets (1/N) and published as a native X thread — the skill chains each tweet as a reply to the previous one via replyToPostId, so readers see a proper thread on your timeline.
Images
Attach up to 4 images per tweet via --image <url>.... URLs must be under https://cdn.gooo.ai/... — YouMind enforces this allowlist server-side to avoid SSRF. Upload local files to YouMind first (via the YouMind product or AI image generation) and reference the resulting CDN URL here.
Paid Plan & Credits
X publishing through YouMind requires a paid plan (Pro or Max). Each published tweet also consumes YouMind credits — the base cost is charged per tweet, with an additional per-image surcharge when attaching images. If your plan is not eligible the OpenAPI returns 402 with an upgrade link; if your credit balance is insufficient, the call fails before the tweet is posted.
---
FAQ
Q: I get a 401 or auth error
Check youmind.api_key in ~/.youmind/config.yaml. The skill now authenticates only with YouMind.
Q: Publishing says X is not connected
Connect X inside YouMind first. The X access token and refresh token live there, not in ~/.youmind/config.yaml.
Q: My image was rejected
YouMind requires media URLs to be under cdn.gooo.ai. External URLs (Imgur, S3, etc.) are rejected with X_MEDIA_HOST_NOT_ALLOWED. Upload the image to YouMind first.
Q: Can I still preview locally without an X connection?
Yes. preview only runs the local adaptation logic and never calls YouMind.
---
License
MIT
YouMind X OpenAPI Reference
Base URL: https://youmind.com/openapi/v1
All X publishing flows through YouMind's OpenAPI proxy. The caller only sends a YouMind API key in x-api-key. The user's X access token and refresh token are stored inside YouMind (connected once via OAuth 2.0 PKCE in the YouMind product) and never placed in the skill config.
Authentication
All requests require:
x-api-key: sk-ym-xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
x-use-camel-case: trueGet your YouMind API key from: <https://youmind.com/settings/api-keys>
Preconditions
- The skill reads
youmind.api_keyandyoumind.base_urlfrom~/.youmind/config.yamlplus optional~/.youmind/config/youmind-x-article.yaml - The user has already connected their X account inside YouMind (one-click OAuth)
- The current YouMind plan allows publishing via OpenAPI (
Pro/Max) — same gating ascreateTokenPlatformPost - The user's credit balance covers the tweet cost (base + per-image). The handler pre-checks credits before calling X and refuses with
InsufficientCreditsExceptionif the balance is too low
Endpoints Used
Create Tweet
POST /openapi/v1/createXPostRequest body:
{
"text": "Hello from YouMind!",
"mediaUrls": [
"https://cdn.gooo.ai/user-files/example-image.jpg"
]
}| Field | Type | Notes |
|---|---|---|
text | string, required | 1-280 characters. URLs still count as 23 characters on X's side. |
mediaUrls | string[], optional | Up to 4 images. Each URL must be under `cdn.gooo.ai` — YouMind enforces this allowlist server-side to avoid SSRF. Non-CDN URLs will be rejected with X_MEDIA_HOST_NOT_ALLOWED. |
replyToPostId | string, optional | Numeric tweet ID (^\d{1,32}$). When set, this tweet is published as a reply — X renders the chain natively. Pass the previous tweet's postId to build a proper thread. |
Response:
{
"postId": "1234567890123456789",
"text": "Hello from YouMind!",
"url": "https://x.com/your-handle/status/1234567890123456789"
}What is NOT in the OpenAPI Today
YouMind's X OpenAPI surface is deliberately narrow. The following are not available through the proxy today, and the skill handles them as follows:
| Missing feature | Skill behavior |
|---|---|
Quote tweet (quote_tweet_id) | Not supported. |
| Delete tweet | Not supported. |
Fetch authenticated user (/users/me) | Not supported — youmind-x validate only checks the ~/.youmind API key. |
| Local media upload (base64 / multipart) | Not supported. Images must already be hosted under cdn.gooo.ai. |
| Long-form article (X Premium, 25K chars) | Not supported yet. |
Threads are fully supported now via replyToPostId — the skill publishes the first tweet, then chains each subsequent tweet to the previous one, producing an X-native thread.
Error Responses
| Status | Meaning |
|---|---|
| 400 | X rejected the request (e.g., duplicate tweet, media URL not under cdn.gooo.ai, invalid replyToPostId) |
| 401 | Invalid or missing YouMind API key, or X access token revoked (YouMind clears the stored credentials in this case) |
| 402 | Current YouMind plan is not eligible for publishing via OpenAPI — upgrade link is in the response |
| 402 (credits) | InsufficientCreditsException — YouMind credit balance is below the tweet cost |
| 403 | X forbids this action (account suspended, app quota exceeded) |
| 404 | X_ACCOUNT_NOT_CONNECTED — the user has not connected an X account in YouMind yet |
| 429 | X rate limit exceeded — includes retryAfterSeconds detail |
| 502 | X API unavailable or media upload failed |
Typical not-connected error (HTTP 404):
{
"message": "X_ACCOUNT_NOT_CONNECTED"
}Connect the X account at <https://youmind.com/settings/connector> (the exact URL is surfaced by the YouMind product flow).
Typical paid-plan error (HTTP 402):
{
"message": "Publishing articles through YouMind OpenAPI requires a paid plan (pro or max). Upgrade at https://youmind.com/pricing.",
"code": "FEATURE_ACCESS_DENIED",
"detail": {
"upgradeUrl": "https://youmind.com/pricing"
}
}Media host rejection:
{
"message": "Media URL host is not allowed.",
"code": "X_MEDIA_HOST_NOT_ALLOWED",
"detail": {
"hint": "Only URLs under cdn.gooo.ai are accepted."
}
}Rate-limited response:
{
"code": "X_RATE_LIMITED",
"retryAfterSeconds": 60
}Adaptation Playbook: Existing Article → X-Native
Use when the user has an existing draft, published article, or content from another platform to bring to X. If the user only has an idea/topic, use content-generation-playbook.md instead.>
Thread decomposition is this playbook's core competency. Breaking long-form content into an X-native thread is the primary use case.
Step 0 — Intent check + sub-mode
Identify the sub-mode before proceeding:
| Sub-mode | Input | Output |
|---|---|---|
| Condense | Long article (>1,000 words) | Thread of 5–15 tweets |
| Cross-post | Short blog post (<1,000 words) | Thread + link back to canonical |
| Excerpt | One section of a longer piece | Teaser thread (3–5 tweets) + link to full |
| Translate | Article in another language | Same-structure thread in target language |
| Revive | Old thread or article | New thread with updated info |
| Localize | Same-language but different audience | Tone/example swap for X's audience |
If no source content exists → redirect to content-generation-playbook.md.
The condense sub-mode is the most common and most valuable — the rest of this playbook optimizes for it.
Step 1 — Source analysis
- Origin platform: Where was this published? (blog, Ghost, Dev.to, Qiita, WeChat, etc.)
- Core thesis (1 sentence): Extract the single central claim or insight
- Claims inventory (bullets): List every distinct claim/point in the source
- Asset inventory: Images, code blocks, diagrams, links, data tables
- Canonical URL: The original publication URL (for link-back in final tweet)
- Word count: Determines thread length strategy
Step 2 — Extract canonical content spec
Same schema as content-generation-playbook.md Step 2:
- Title/hook candidates (3): Distilled from the source thesis
- Outline: Ordered claims, now assessed for thread viability
- Key assets: Which images/data can fit X's media constraints?
- CTA: Link back to canonical source
- Voice markers: Should the thread match source voice or shift to X voice?
Step 3 — Gap analysis vs platform DNA
Read references/platform-dna.md. Assess the source against X's constraints:
| Dimension | Gap analysis question |
|---|---|
| Length | Source is >280 chars? → Thread needed. How many atomic claims? |
| Hook | Does the source have a strong opening, or does it need a new hook for X? |
| Self-containment | Can each claim stand alone as a single tweet? |
| Media | Are there usable images, or do we need to create/drop them? |
| Code | Are there code blocks? (Must become screenshots or be dropped) |
| Links | Where does the canonical link go? (Last tweet only) |
| Tone | Is the source tone X-native, or does it need register shift? |
| Math/formulas | Any LaTeX? (Must render as images) |
Step 4 — Restructure: Thread Decomposition
This is the core transformation. Follow these rules precisely.
Thread decomposition algorithm
4.1 — Thesis extraction Distill the source to a single sentence. This becomes the candidate hook tweet.
4.2 — Atomic claim identification Go through the source and extract 5–15 atomic claims — points that:
- Support the thesis
- Each stand alone (readable without the others)
- Each fit approximately within 280 characters
- Each are valuable if retweeted individually
4.3 — Claim fitness check For each atomic claim: can it fit in 280 chars (including any image reference)?
- Yes → becomes one tweet
- Barely (250–280 chars) → tighten the language
- No (>280 chars) → split into two consecutive tweets OR drop the claim
- Not every source claim must survive — prioritize the strongest
4.4 — Hook tweet construction The hook tweet is the most important tweet. Rules:
- State the thesis / promise / curiosity gap directly
- NO "here's a thread about X" — show, don't announce
- If there's a killer stat or example, lead with it
- Test: would someone who sees ONLY this tweet find it worth reading?
Hook templates (choose the best fit):
[Contrarian take]: "Everyone says X. Wrong. Here's what actually works:"
[Stat lead]: "90% of [audience] do X. But the top 1% do Y instead:"
[Story lead]: "3 years ago I [painful thing]. Today I [success]. Here's the path:"
[Checklist]: "How to [achieve X] in [N] steps:"
[Curiosity]: "The most underrated [thing] is [thing]. Most people miss this:"4.5 — Body tweet rules
- Each tweet = one claim, self-contained
- No "as I said above" — reader may land mid-thread via RT
- Line breaks improve readability; use them
- Emoji: sparingly, for visual scanning (1–2 per tweet max)
- Data/numbers in tweets earn higher engagement
- Plain text only — no markdown (X doesn't render it)
- Code: screenshots only (no syntax highlighting on X)
4.6 — Final tweet rules
- TL;DR of the thread (1 sentence)
- Link to canonical source (blog post, article URL)
- Optional CTA: "If you liked this, follow + RT the first tweet"
- No image in final tweet (let the link preview breathe)
4.7 — Media placement strategy
- Hook tweet: image/chart if it reinforces the stat or claim
- Body tweets: images only when genuinely illuminating (screenshots, diagrams)
- No images just for decoration — X users scroll past stock visuals
- Max 4 images per tweet; 1 per tweet is typical in threads
Thread length guidelines
| Source length | Recommended thread length |
|---|---|
| 500–1,000 words | 3–5 tweets |
| 1,000–2,000 words | 5–10 tweets |
| 2,000–3,000 words | 8–15 tweets |
| 3,000+ words | 12–15 tweets (cap) or split into 2 threads |
Step 5 — Transcreate
If the source and X thread are in the same language, transcreation is lighter but still applies:
- Register shift: Academic/formal → conversational; blog → punchy
- Example localization: Niche/domain examples → broader-appeal examples
- Jargon reduction: Technical terms may need simplification for X's broader audience
- Hook rewrite: Source article's opening rarely works as a tweet hook — rewrite from scratch
If translating across languages:
- Don't literally translate — rebuild the thread natively in the target language
- Hook must work in the target language's X culture
- Examples and references should be relevant to the target audience
Step 6 — Constraint conflict resolution
| Conflict | Resolution |
|---|---|
| Source thesis too complex for thread (>15 claims) | Option 1: Premium Long Post instead. Option 2: Two separate threads. Option 3: Thread = summary + link to canonical |
| Source has extended code samples | Screenshot key code; link to Gist/repo in final tweet |
| Source requires LaTeX/math | Render formulas as images |
| Source requires interactive elements | X Poll for single-question engagement; else link out |
| Source images from cdn.gooo.ai | CDN URLs are allowlisted for X posts via YouMind API |
| Too many claims to fit 15 tweets | Prioritize strongest 10; mention "full version" with link |
Never silently drop the thesis. If condensing loses the core argument, flag to the user and suggest an alternate platform.
Step 7 — Self-critique
- [ ] Platform-fit: Does this thread look native to X? (Not a blog post chopped into 280-char chunks)
- [ ] Hook earns the scroll: Read tweet 1 as a stranger — would you keep reading?
- [ ] Self-contained test: Pick any body tweet — does it make sense alone?
- [ ] 280-char compliance: Every tweet within limit?
- [ ] Thesis fidelity: Core argument preserved from source?
- [ ] Attribution: Canonical URL in final tweet? Source credited?
- [ ] Link placement: External links in last tweet only?
- [ ] No mid-thread fluff: Every tweet adds value?
- [ ] Media discipline: Images only where they add genuine value?
- [ ] Thread length: 5–15 tweets? Flagged if >15?
- [ ] Voice match: Thread voice matches intended brand/author?
Step 8 — Conformance report
### Conformance Report
- **Platform DNA rules applied:** [list]
- **Deliberate deviations:** [any rule broken and why]
- **Unresolved mismatches:** [gaps the user should know]
- **Adaptation stats:** [source word count → tweet count, compression ratio, claims kept/dropped]
- **Thread structure:** [hook type, body tweet count, media count, link placement]
- **Fidelity:** [thesis preserved ✓/✗, key claims preserved N/M]X (Twitter) Content Adaptation Guide
Character Limits
Single Tweet
- Hard limit: 280 characters
- URLs: Always count as 23 characters, regardless of actual length
- Media: Images/videos don't count toward character limit
- Mentions: @username counts toward limit
- Hashtags: #tag counts toward limit (use 1-2 max)
Thread (Tweet Chain)
- Each tweet in the chain: 280 characters (including optional numbering)
- No hard limit on thread length (but 5-15 tweets is ideal)
- Numbering format: "1/N" at end of each tweet — optional but recommended for scanability
- Publishing is a native X reply-chain: the skill passes the previous tweet's
postIdasreplyToPostIdon each subsequent call, so X renders the sequence as a proper thread.
Single Tweet Best Practices
1. Lead with the hook -- first words determine engagement 2. One idea per tweet -- clarity beats comprehensiveness 3. Strong opinion or insight -- vague tweets get ignored 4. 1-2 hashtags max -- more than 2 looks spammy 5. No link in tweet body -- reply with link for better reach 6. Use line breaks -- improves readability
Thread Writing Strategy
Structure
1. Tweet 1 (Hook): Must stand alone. Bold claim, surprising stat, or provocative question. This tweet gets shared individually. 2. Tweets 2-N (Body): Each adds one point. Each should be valuable standalone. Use transition words sparingly. 3. Last tweet (CTA): Summary + call to action. "Follow for more" or specific ask.
Splitting Algorithm
1. Split on paragraph boundaries (double newline) 2. If a paragraph exceeds 280 chars, split on sentence boundaries 3. Each tweet includes numbering: "1/N" 4. Numbering reduces available chars by ~5-6 5. Never split mid-sentence 6. Each tweet should make sense if read in isolation
Thread Tips
- Start a new tweet for each new idea or example
- Use emoji at the start of tweets for visual scanning
- Include a "save/bookmark this" reminder in the middle
- End with engagement hook ("What would you add?")
Markdown Stripping
X does not support Markdown in tweets. Content adapter strips:
#headers -> plain text**bold**-> plain text*italic*-> plain text- `
code` -> plain text [text](url)-> text url- Code blocks -> removed
- Images -> removed
- Horizontal rules -> removed
Hashtag Strategy
- 1-2 hashtags per tweet -- X algorithm preference
- Place at the end of the tweet
- Mix broad and niche tags
- Check trending hashtags for timing
- Don't use hashtags in thread body tweets (only first or last)
Engagement Optimization
1. Post timing: 8-10 AM, 12-1 PM, 5-6 PM local time 2. Reply to comments within first hour 3. Tweet sequence > single tweet for complex topics (5x more engagement historically; numbered sequences still read well even without native reply chains) 4. Images boost engagement by ~150% — reference https://cdn.gooo.ai/... URLs only
Generation Playbook: Idea → X-Native Draft
Use when the user has a topic, thesis, or angle but no existing draft. If the user has any existing content (article, notes, outline), use content-adaptation-playbook.md instead.Step 0 — Intent check
Before generating, confirm the user actually wants new content creation. If the intent matches any of these, redirect:
| User intent | Redirect to |
|---|---|
| Has a long article to turn into tweets | content-adaptation-playbook.md (condense mode) |
| Wants to translate existing content | content-adaptation-playbook.md (translate mode) |
| Wants to update an old thread | content-adaptation-playbook.md (revive mode) |
| Wants a teaser for another post | content-adaptation-playbook.md (excerpt mode) |
If the user has only an idea, topic, or talking points → continue with generation.
Step 1 — Evidence brief
Before writing any tweet text, the user (or the skill working with the user) must produce an evidence brief. X content without substance is indistinguishable from noise.
Required:
- Thesis (1 sentence): The single claim or insight this thread/tweet will deliver
- Supporting claims (2–5 bullets): The atomic points that support the thesis
- Evidence per claim: Source, data point, personal experience, or example
- Reader problem: What the reader cares about that makes this relevant
- Desired takeaway: What the reader should think, feel, or do after reading
- Voice/brand constraints: Formal? Casual? Technical? Personal brand tone?
If the user cannot provide evidence for their claims → stop and ask. An evidence-free tweet thread is just noise.
Step 2 — Canonical content spec
Produce a platform-agnostic content spec (this is the pivot representation that adaptation playbooks also consume):
- Title/hook candidates (3 options): Not tweet text yet — just the core hook ideas
- Outline: Ordered list of claims/points (platform-agnostic)
- Key assets: Images needed, data visualizations, code screenshots, links
- CTA: What should the reader do after consuming this?
- Voice markers: Tone words that define the brand voice
Step 3 — Apply platform DNA
Read references/platform-dna.md. Map the canonical spec to X's constraints:
- Length check: Can the thesis fit in 1 tweet (280 chars)? → Single tweet. Does it need 2–5 claims? → Short thread. 5+ claims? → Full thread.
- Hook selection: Which of the 3 hook candidates works best as an X-native opening? (contrarian > stat > story > checklist > curiosity gap — based on observed platform performance)
- Thread structure: Map outline points to individual tweets. Each tweet = one self-contained claim.
- Media plan: Which claims benefit from an image, chart, or screenshot? Allocate 1–4 images per tweet max.
- Link strategy: External link goes in the LAST tweet only (algorithm de-amplifies mid-thread links).
- Hashtag plan: 0–2 total, placed in hook tweet or final tweet only.
Step 4 — Draft (X-native from scratch)
Write the thread/tweet directly in X's native format. Do NOT write a blog post and then try to fit it into tweets.
Single tweet format
[Hook — strong opinion, stat, or insight]
[Supporting detail or example — 1-2 lines]
[CTA or takeaway — optional]Total: ≤280 characters.
Thread format
Tweet 1 (Hook):
[Contrarian take / stat / story opener — must stand alone and earn the scroll]
Tweet 2-N (Body):
[One claim per tweet, self-contained, rebloggable in isolation]
[Each tweet should make sense if seen individually in someone's feed]
Final tweet (Close):
[TL;DR — 1 sentence recap]
[CTA: follow / RT / bookmark]
[Link to canonical long-form source — if applicable]X-native writing rules:
- Each tweet is a paragraph, not a sentence fragment
- No "as I mentioned above" — reader may see any tweet in isolation
- Line breaks improve readability; use them
- Emoji: sparingly, for visual scanning (not decoration)
- No markdown (X doesn't render it) — plain text only
- Code: use screenshots, not inline code (no syntax highlighting on X)
- Numbers and stats in tweets earn higher engagement
Step 5 — Constraint conflict resolution
If a canonical-spec element cannot fit X's constraints, apply the resolution ladder in order:
1. Refuse + recommend: "This content requires extended exposition — recommend publishing as a long-form blog post and creating a teaser thread with link." 2. Serialize: Break into a thread of 5–15 tweets; each tweet = one atomic claim. 3. Summarize + link: Thread = summary of key points + link to canonical full-length source. 4. Split: Hook thread on X + companion long-form on another platform.
Never silently truncate. If a claim can't fit 280 chars even after rewriting, either split it across 2 tweets or drop it with a note to the user.
Common conflict scenarios on X:
- Long argument (>15 claims) → recommend Long Post (Premium) or serialize into 2 threads
- Code-heavy content → use screenshots; if too many, link to Gist/repo
- Content requiring LaTeX/math → render as image
- Nuanced topic requiring caveats → add a "caveat thread" or link to long-form
Step 6 — Self-critique
Before presenting to the user, run this checklist:
- [ ] Platform-fit: Does this look native to X? (Not a blog post chopped into tweets)
- [ ] Hook test: Would the first tweet earn a scroll if seen cold in a feed? Read it as a stranger.
- [ ] Self-contained test: Pick any body tweet at random — does it make sense alone?
- [ ] 280-char compliance: Every tweet within limit (URLs = 23 chars each)
- [ ] Factuality: Every claim is sourced, hedged, or first-person experience
- [ ] Link integrity: Final tweet link is correct; no mid-thread external links
- [ ] Voice match: Tone matches the brand/voice from evidence brief
- [ ] Hashtag discipline: 0–2 total, not in every tweet
- [ ] Thread length: 5–15 tweets (sweet spot); flag if >15
Step 7 — Conformance report
Output alongside the draft:
### Conformance Report
- **Platform DNA rules applied:** [list which platform-dna.md items shaped the draft]
- **Deliberate deviations:** [any rule we intentionally broke and why]
- **Unresolved mismatches:** [known gaps the user should be aware of]
- **Thread stats:** [tweet count, avg chars/tweet, media count, link placement]X Publishing Pipeline
All publishing flows through YouMind's OpenAPI. The caller holds only a YouMind API key; X OAuth tokens live inside YouMind.
Step-by-Step Execution
Step 1 -- Load Configuration
- Read
~/.youmind/config.yamlforyoumind.api_key(and optionalyoumind.base_url) - Validate the API key is present
- The X account itself is not configured locally — it is connected once inside YouMind
Step 2 -- Research Topic (Optional)
- Mine the YouMind knowledge base for relevant material (
mineTopics) - Use web search for trending angles (
webSearch) - Identify current conversations to join
Step 3 -- Write Content
- Determine format: single tweet or multi-tweet sequence
- Single tweet: craft a punchy 280-char message with a strong hook
- Thread: write structured long-form content, then split
Step 4 -- Adapt Content
- Single tweet: Run through
adaptSingleTweet() - Strip Markdown, enforce 280 chars, add 1-2 hashtags
- Thread: Run through
splitIntoThread() - Split at paragraph boundaries
- Add numbering (
1/N) into the text - Ensure each tweet makes sense standalone
- Hook in first tweet, CTA in last
Step 5 -- Prepare Images (Optional)
- If images are provided, they must already be hosted under
https://cdn.gooo.ai/... - Local files cannot be uploaded by the skill — upload them to YouMind first, then pass the resulting CDN URL
- At most 4 image URLs per tweet
Step 6 -- Publish via YouMind OpenAPI
- Single tweet:
POST /openapi/v1/createXPostwith{ text, mediaUrls? } - Thread: call
/createXPostonce per tweet in order, passing each previous tweet'spostIdasreplyToPostIdto the next call. X renders the chain as a native thread. - Images, if provided, attach to the first tweet only
- Both return
{ postId, text, url }
Step 7 -- Archive to YouMind (Optional)
- Save the published content back to the YouMind knowledge base via
saveArticle - Record post IDs for future reference
Step 8 -- Report Results
- Display post IDs and URLs
- Include result links (lead tweet URL first, best platform entry URL as fallback)
- Show character counts
- List any warnings (adaptation truncation, rejected non-CDN media URLs, thread-chain degradation)
- Provide engagement tips
Fallbacks
| Step | Fallback |
|---|---|
| 2 Research | Skip, write from topic alone |
| 5 Images | Warn, publish without media (non-CDN URLs are dropped with a warning) |
| 6 Publish | Save adapted text locally under output/ for manual posting |
| 7 Archive | Warn, continue |
Decision: Tweet vs Thread
- Content < 280 chars → single tweet
- Content 280-2000 chars → thread (3-8 tweets)
- Content 2000+ chars → long thread (8+)
- User explicitly says "thread" → always thread
- User explicitly says "tweet" → single tweet (truncate if needed)
Threads publish as a native X reply chain. Prefer a concise single tweet when the topic fits — save threads for when the user explicitly asks for one or the content genuinely needs multi-tweet pacing.
X (Twitter) Platform DNA
Scope: This file describes observable platform behavior — format constraints, discourse norms, moderation signals, and content patterns derived from platform data and high-performing posts. It does NOT make claims about audience psychology, ethnicity, or cultural generalizations. All guidance derives from what the platform's algorithm rewards and what its community tolerates.
Platform snapshot
- Primary surface is the public timeline: hooks, quote-worthy claims, and reply-thread continuation matter more than long-form completeness
- Threads are the native way to serialize multi-claim arguments when one post cannot hold the idea cleanly
- Quote tweets, communities, polls, and media attachments are separate distribution levers with distinct tradeoffs
- External links are tolerated but should be delayed until the final tweet when the goal is distribution
- Premium long posts exist, but the platform's default reading habit is still fast-scanning short posts
Format constraints
| Element | Constraint |
|---|---|
| Post length | 280 characters (free); 25,000 characters (Premium Long Post) |
| Thread length | No hard cap; 5–15 posts practical; 25+ risks severe completion drop-off |
| URLs | Always count as 23 characters regardless of actual length |
| Media per post | 1–4 images OR 1 video OR 1 GIF (media does not count toward char limit) |
| Hashtags | 0–2 per post; algorithm treats 3+ as spam signal |
| Mentions | @username counts toward character limit |
| Polls | 2–4 options; 5 min to 7 days duration |
| Alt text | Up to 1,000 characters per image |
| Video length | 2 min 20 sec (free); up to 60 min (Premium) |
Discourse norms (observable)
Opening patterns (high-performing hooks)
- Contrarian take: "Everyone says X. Wrong." — earns engagement through disagreement
- Stat / proof lead: "90% of Y. Here's why that matters:" — data earns credibility
- Story opener: "3 years ago I X. Today Y. Here's what I learned:" — personal journey
- Checklist promise: "How to X in 5 steps:" — clear value proposition
- Curiosity gap: "The most underrated X is Y." — earns the scroll
Closing patterns
- Thread TL;DR in final tweet (1-sentence recap)
- CTA: follow / RT / bookmark / link to canonical long-form
- Link placement: external links in the LAST tweet only (algorithm de-amplifies mid-thread links)
Citation conventions
- Quote tweet for attribution (higher engagement than reply-with-link)
- Screenshot of source with credit line
- Thread-native: no footnotes, no bibliography; each tweet is self-contained
Self-promo tolerance
- Moderate. "Build in public" culture rewards transparent self-promotion
- Overt ads and affiliate links are de-amplified
- Product launches via personal story threads perform well
- Pure marketing copy without personal context underperforms
Moderation & flagging patterns
- Removed: Hate speech, doxxing, impersonation, spam bots, coordinated inauthentic behavior
- De-amplified: Engagement bait ("Am I the only one who...?"), excessive hashtags (3+), external links mid-thread, reply-spam, rapid-fire posting without engagement
- Shadowban signals: Posting identical content repeatedly, follow/unfollow churn, bulk mentions
Platform-native features to leverage
| Feature | When / why |
|---|---|
| Threads (reply chain) | Use when the idea genuinely needs a multi-claim chain instead of one self-contained post |
| Quote tweets | Attribution + commentary; higher reach than reply-with-link |
| Polls | Audience validation; high native completion rate |
| Spaces (live audio) | Discussion / Q&A; notification push drives attendance |
| Communities | Niche targeting; focused distribution |
| Premium Long Post | Essay-length; search-indexed; no thread fragmentation |
| Bookmark prompt | Mid-thread signals save-worthy value to the algorithm |
Hard limits (must not violate)
- 280 characters per post (free tier) — absolutely enforced, no exceptions
- 4 images per post maximum
- 1 video per post maximum
- No markdown rendering — plain text + line breaks only
- External link previews work best in the last tweet of a thread
- Numbering ("1/N"): optional; many high-performing threads omit it entirely
- Code does not render with syntax highlighting — use screenshots for code blocks
Anti-patterns
| Anti-pattern | Why it fails |
|---|---|
| "This is a thread 🧵" without substance | Hook wasted on announcement, not value |
| Generic advice listicle | Indistinguishable from thousands of identical threads |
| Thread bloat (25+ posts) | Completion rate drops sharply past ~15 |
| Teaser without payoff | "Ever wondered why X?" then silence — kills trust |
| Engagement bait | "Am I the only one who..." — de-amplified |
| Premature reveal | Hook gives away the answer — no reason to continue |
| Code blocks in tweet text | No syntax highlighting; wraps poorly; unreadable |
| Mid-thread external links | Algorithm deprioritizes distribution |
| Images from cdn.gooo.ai in tweets | YouMind CDN URLs work (allowlisted for X posts via API) but only up to 4 per tweet |
Example calibration patterns
High-performing thread (observable structure): 1. Hook tweet: stat or contrarian take (earns the scroll) 2. Body tweets 2–10: each self-contained, one claim per tweet, rebloggable in isolation 3. Final tweet: TL;DR + CTA + link to canonical source
High-performing single tweet:
- Clear opinion + evidence in ≤200 chars + relevant image
- Or: short personal story with specific outcome
Distribution calibration:
- Threads outperform single posts when the thesis benefits from serialized claims and the hook earns continuation.
- Single posts outperform threads when the idea is truly atomic.
- Media should reinforce the claim, not decorate it.
youmind:
api_key: "sk-ym-xxxxxxxxxxxxxxxxxxxx"
base_url: "https://youmind.com/openapi/v1"
Publishing Guidelines
This file captures shared publishing rules for YouMind skills that create drafts, publish posts, or hand content off to external platforms.
Default Safety Rules
- Prefer draft mode first unless the user explicitly asks for direct publish.
- Validate the remote connection before the first publish attempt.
- Report the platform object ID and public URL when available.
- Never stop at raw IDs. Always return the best clickable result links after draft/publish actions.
Smoke Test Rules
- Use clearly labeled test content.
- Clean up published artifacts after verification when the platform supports deletion.
- If deletion is unavailable, unpublish or revert to draft.
- If the upstream platform cannot delete test artifacts, state that limitation explicitly.
Failure Handling
- Do not treat a single publish failure as a total pipeline failure unless no fallback exists.
- Surface the exact upstream error message when possible.
- Distinguish auth failure, plan limitation, content validation failure, and transport failure.
Output Expectations
A successful publish or draft operation should return enough information for follow-up actions:
titlestatusurlresult_linkspost_idordraft_idnotes
result_links should be a labeled list of the best follow-up URLs for the user.
- Prefer the direct public post URL when it exists.
- Also include the draft/dashboard/admin/results URL when the platform exposes one.
- If no precise analytics or management URL exists, include the best platform entry URL instead.
- Never end the run with only opaque IDs when a clickable link is possible.
Shared Config Rule
Publishing-capable skills should load runtime configuration from ~/.youmind/.
YouMind Home Directory
YouMind article skills now share one canonical home directory:
~/.youmind/
config.yaml
config/
youmind-wechat-article.yaml
youmind-x-article.yaml
...
author-profile.yaml
articles/
wechat/
x/
...
clients/
<client>/
style.yaml
history.yaml
playbook.mdResolution Order
For article-skill config, use this order:
1. ~/.youmind/config/<skill>.yaml 2. ~/.youmind/config.yaml
This is now a hard requirement. Article skills do not read repo-local config.yaml files or ~/.youmind-skill/credentials.yaml anymore.
Canonical Files
- Shared API credentials:
~/.youmind/config.yaml - Skill-specific overrides:
~/.youmind/config/<skill>.yaml - Cross-platform author profile:
~/.youmind/author-profile.yaml - Local article drafts:
~/.youmind/articles/<platform>/<slug>.md
Migration Notes
- If you still have
~/.youmind-skill/credentials.yaml, migrate the needed values into~/.youmind/config.yamlor~/.youmind/config/<skill>.yaml. - If a skill still has a local
config.yaml, treat it as obsolete and remove or ignore it. - Put local backend overrides in
~/.youmind/config/<skill>.yamlinstead of editing tracked files.
{
"name": "youmind-x-toolkit",
"version": "1.0.0",
"description": "YouMind X: Write and publish tweets and threads to X (Twitter)",
"type": "module",
"main": "dist/cli.js",
"bin": {
"youmind-x": "dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"tweet": "tsx src/cli.ts tweet",
"thread": "tsx src/cli.ts thread",
"preview": "tsx src/cli.ts preview"
},
"dependencies": {
"commander": "^12.0.0",
"node-fetch": "^3.3.0",
"yaml": "^2.3.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
#!/usr/bin/env tsx
/**
* CLI entry point for YouMind X Skill.
*
* All publishing flows through YouMind's OpenAPI proxy. The user only needs a
* YouMind API key locally; their X account is connected once inside YouMind.
*
* Usage:
* npx tsx src/cli.ts tweet --text "Your tweet here"
* npx tsx src/cli.ts tweet --text "Check this out" --image ./cover.png
* npx tsx src/cli.ts thread --file article.md
* npx tsx src/cli.ts preview --text "Check length" --mode tweet
* npx tsx src/cli.ts preview --file article.md --mode thread
* npx tsx src/cli.ts validate
*/
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { deleteXPost, loadXConfig } from './x-api.js';
import {
previewThread,
previewTweet,
publishThread,
publishTweet,
} from './publisher.js';
const program = new Command();
program
.name('youmind-x')
.description('YouMind X: Write and publish tweets and threads via YouMind OpenAPI')
.version('1.0.0');
program
.command('tweet')
.description('Publish a single tweet')
.option('--text <text>', 'Tweet text content')
.option('--file <path>', 'Read content from a file')
.option('--image <pathsOrUrls...>', 'Local image paths or cdn.gooo.ai URLs (max 4)')
.option('--hashtags <tags>', 'Comma-separated hashtags (max 2)')
.action(async (opts) => {
let content = opts.text || '';
if (opts.file) {
content = readFileSync(resolve(opts.file), 'utf-8');
}
if (!content) {
console.error('Error: --text or --file required');
process.exit(1);
}
const config = loadXConfig();
if (!config.apiKey) {
console.error('[ERROR] youmind.api_key not set. Configure ~/.youmind/config.yaml.');
process.exit(1);
}
const hashtags = opts.hashtags
? opts.hashtags.split(',').map((h: string) => h.trim())
: undefined;
const result = await publishTweet({
content,
mediaUrls: opts.image,
hashtags,
config,
});
if (result.success) {
const post = result.posts[0];
console.log('\nTweet published via YouMind OpenAPI!');
console.log(`Post ID: ${post.postId}`);
console.log(`URL: ${post.url}`);
} else {
console.error(`\nPublish failed: ${result.error}`);
}
if (result.warnings.length > 0) {
console.log('\nWarnings:');
for (const w of result.warnings) console.log(` - ${w}`);
}
console.log(`\nText (${(result.content as string).length} chars):`);
console.log('---');
console.log(result.content);
console.log('---');
});
program
.command('thread')
.description('Publish a sequence of numbered tweets from long-form content')
.option('--text <text>', 'Thread content')
.option('--file <path>', 'Read content from a file')
.option('--image <pathsOrUrls...>', 'Local image paths or cdn.gooo.ai URLs attached to the first tweet only')
.option('--hashtags <tags>', 'Comma-separated hashtags')
.option('--no-numbering', 'Disable tweet numbering')
.action(async (opts) => {
let content = opts.text || '';
if (opts.file) {
content = readFileSync(resolve(opts.file), 'utf-8');
}
if (!content) {
console.error('Error: --text or --file required');
process.exit(1);
}
const config = loadXConfig();
if (!config.apiKey) {
console.error('[ERROR] youmind.api_key not set. Configure ~/.youmind/config.yaml.');
process.exit(1);
}
const hashtags = opts.hashtags
? opts.hashtags.split(',').map((h: string) => h.trim())
: undefined;
const result = await publishThread({
content,
mediaUrls: opts.image,
hashtags,
addNumbering: opts.numbering !== false,
config,
});
if (result.success) {
console.log(
`\nThread published via YouMind OpenAPI (${result.posts.length} tweets)`,
);
for (let i = 0; i < result.posts.length; i++) {
console.log(` ${i + 1}. ${result.posts[i].url}`);
}
} else {
console.error(`\nPublish failed: ${result.error}`);
if (result.posts.length > 0) {
console.log(`Published before failure: ${result.posts.length}`);
for (let i = 0; i < result.posts.length; i++) {
console.log(` ${i + 1}. ${result.posts[i].url}`);
}
}
}
if (result.warnings.length > 0) {
console.log('\nWarnings:');
for (const w of result.warnings) console.log(` - ${w}`);
}
const tweets = Array.isArray(result.content) ? result.content : [String(result.content)];
console.log(`\nThread preview (${tweets.length} tweets):`);
for (let i = 0; i < tweets.length; i++) {
console.log(`\n--- Tweet ${i + 1} ---`);
console.log(tweets[i]);
}
});
program
.command('preview')
.description('Preview formatted content without publishing')
.option('--text <text>', 'Content text')
.option('--file <path>', 'Read content from a file')
.option('--mode <mode>', 'tweet or thread', 'tweet')
.option('--hashtags <tags>', 'Comma-separated hashtags')
.option('--no-numbering', 'Disable thread numbering')
.action((opts) => {
let content = opts.text || '';
if (opts.file) {
content = readFileSync(resolve(opts.file), 'utf-8');
}
if (!content) {
console.error('Error: --text or --file required');
process.exit(1);
}
const hashtags = opts.hashtags
? opts.hashtags.split(',').map((h: string) => h.trim())
: undefined;
if (opts.mode === 'thread') {
const result = previewThread(content, {
hashtags,
addNumbering: opts.numbering !== false,
});
console.log(`Thread preview: ${result.totalTweets} tweets\n`);
for (let i = 0; i < result.tweets.length; i++) {
console.log(`--- Tweet ${i + 1} ---`);
console.log(result.tweets[i]);
console.log('');
}
if (result.warnings.length > 0) {
console.log('Warnings:');
for (const w of result.warnings) console.log(` - ${w}`);
}
} else {
const result = previewTweet(content, { hashtags });
console.log(`Character count: ${result.charCount}/280`);
if (result.warnings.length > 0) {
console.log('\nWarnings:');
for (const w of result.warnings) console.log(` - ${w}`);
}
console.log('\n--- Preview ---');
console.log(result.text);
console.log('--- End ---');
}
});
program
.command('validate')
.description('Validate YouMind credentials from ~/.youmind config')
.action(() => {
const config = loadXConfig();
if (!config.apiKey) {
console.error('[ERROR] youmind.api_key not set. Configure ~/.youmind/config.yaml.');
console.error(
'Get a key: https://youmind.com/settings/api-keys?utm_source=youmind-x-article',
);
process.exit(1);
}
console.log('YouMind API key is configured.');
console.log(`Base URL: ${config.baseUrl}`);
console.log(
'X account connection is validated on the first publish call; ' +
'if your X account is not connected in YouMind yet, publishing will return a clear error with a connect URL.',
);
});
program
.command('delete')
.description('Delete a tweet by ID')
.argument('<postId>', 'Tweet ID')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (postId: string, opts) => {
const config = loadXConfig();
if (!config.apiKey) {
console.error('[ERROR] youmind.api_key not set. Configure ~/.youmind/config.yaml.');
process.exit(1);
}
if (!opts.yes) {
console.error(`Refusing to delete ${postId} without --yes. Re-run with -y to confirm.`);
process.exit(1);
}
try {
const result = await deleteXPost(config, postId);
if (!result.ok) {
console.error(`Delete returned ok=false for ${result.postId}.`);
process.exit(1);
}
console.log(`Deleted tweet ${result.postId}.`);
} catch (err) {
console.error(`Delete failed: ${(err as Error).message}`);
process.exit(1);
}
});
program.parse();
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SKILL_CONFIG_NAME = 'youmind-x-article';
export const DEFAULT_YOUMIND_OPENAPI_BASE_URL = 'https://youmind.com/openapi/v1';
export const YOUMIND_CONFIG_ERROR_HINT =
`Set youmind.api_key in ~/.youmind/config.yaml. Optional skill overrides live in ~/.youmind/config/${SKILL_CONFIG_NAME}.yaml.`;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || '';
}
function loadYamlFile(path: string): Record<string, unknown> {
if (!path || !existsSync(path)) {
return {};
}
return (parseYaml(readFileSync(path, 'utf-8')) ?? {}) as Record<string, unknown>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasConfiguredValue(value: unknown): boolean {
if (value === undefined || value === null) {
return false;
}
if (typeof value === 'string') {
return value.trim().length > 0;
}
return true;
}
function mergeWithFallback(
primary: Record<string, unknown>,
fallback: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...primary };
for (const [key, fallbackValue] of Object.entries(fallback)) {
const currentValue = result[key];
if (isPlainObject(currentValue) && isPlainObject(fallbackValue)) {
result[key] = mergeWithFallback(currentValue, fallbackValue);
continue;
}
if (!hasConfiguredValue(currentValue) && hasConfiguredValue(fallbackValue)) {
result[key] = fallbackValue;
}
}
return result;
}
function loadSharedConfig(): Record<string, unknown> {
const home = getHomeDir();
if (!home) {
return {};
}
return loadYamlFile(resolve(home, '.youmind', 'config.yaml'));
}
function loadSkillOverrideConfig(): Record<string, unknown> {
const home = getHomeDir();
if (!home) {
return {};
}
return loadYamlFile(resolve(home, '.youmind', 'config', `${SKILL_CONFIG_NAME}.yaml`));
}
export function normalizeBaseUrl(value: string | undefined): string {
if (!value) return '';
const trimmed = value.replace(/\/+$/, '');
if (trimmed.endsWith('/openapi/v1')) return trimmed;
if (trimmed.endsWith('/openapi')) return `${trimmed}/v1`;
return `${trimmed}/openapi/v1`;
}
function getImageYouMindConfig(raw: Record<string, unknown>): Record<string, unknown> {
const image = raw.image;
if (!isPlainObject(image)) {
return {};
}
const providers = image.providers;
if (!isPlainObject(providers)) {
return {};
}
const youmind = providers.youmind;
return isPlainObject(youmind) ? youmind : {};
}
export function loadLayeredConfig(): Record<string, unknown> {
return mergeWithFallback(loadSkillOverrideConfig(), loadSharedConfig());
}
export function loadYouMindConfig(): {
apiKey: string;
baseUrl: string;
raw: Record<string, unknown>;
} {
const raw = loadLayeredConfig();
const youmind = isPlainObject(raw.youmind) ? raw.youmind : {};
const imageYouMind = getImageYouMindConfig(raw);
const apiKey =
(typeof youmind.api_key === 'string' && youmind.api_key) ||
(typeof imageYouMind.api_key === 'string' && imageYouMind.api_key) ||
'';
const configuredBaseUrl =
typeof youmind.base_url === 'string' ? normalizeBaseUrl(youmind.base_url) : '';
return {
raw,
apiKey,
baseUrl: configuredBaseUrl || DEFAULT_YOUMIND_OPENAPI_BASE_URL,
};
}
/**
* X (Twitter) content adaptation module.
*
* Handles:
* - Single tweet: 280 character limit (URLs = 23 chars)
* - Thread splitting algorithm with smart paragraph boundaries
* - Tweet numbering (1/N format)
* - Hashtag management (1-2 max)
* - Long-form article support (25K chars, X Premium)
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TWEET_CHAR_LIMIT = 280;
const URL_CHAR_COUNT = 23; // X counts all URLs as 23 chars
const THREAD_NUMBER_OVERHEAD = 6; // " (1/N)" at the end, max 6 chars for small threads
const LONG_FORM_LIMIT = 25_000;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SingleTweetResult {
text: string;
charCount: number;
wasTruncated: boolean;
warnings: string[];
}
export interface ThreadResult {
tweets: string[];
totalTweets: number;
totalChars: number;
warnings: string[];
}
export interface AdaptOptions {
/** Maximum chars per tweet (default: 280) */
maxChars?: number;
/** Hashtags to include (1-2 recommended) */
hashtags?: string[];
/** Whether to add thread numbering */
addNumbering?: boolean;
/** Force long-form article mode (X Premium, 25K chars) */
longForm?: boolean;
}
// ---------------------------------------------------------------------------
// URL handling
// ---------------------------------------------------------------------------
const URL_REGEX = /https?:\/\/[^\s)]+/g;
/**
* Calculate the "weighted" character count where URLs count as 23 chars.
*/
export function weightedCharCount(text: string): number {
const urls = text.match(URL_REGEX) || [];
let count = text.length;
for (const url of urls) {
// Each URL is counted as 23 chars regardless of length
count = count - url.length + URL_CHAR_COUNT;
}
return count;
}
// ---------------------------------------------------------------------------
// Single tweet adaptation
// ---------------------------------------------------------------------------
/**
* Adapt content for a single tweet (280 chars).
*/
export function adaptSingleTweet(
content: string,
options: AdaptOptions = {},
): SingleTweetResult {
const maxChars = options.maxChars ?? TWEET_CHAR_LIMIT;
const warnings: string[] = [];
// Strip Markdown formatting
let text = stripMarkdown(content).trim();
// Add hashtags
if (options.hashtags?.length) {
const hashtagStr = options.hashtags
.slice(0, 2) // Max 2 hashtags on X
.map((h) => (h.startsWith('#') ? h : `#${h}`))
.join(' ');
text = text + '\n\n' + hashtagStr;
}
// Check length
let wasTruncated = false;
const weighted = weightedCharCount(text);
if (weighted > maxChars) {
wasTruncated = true;
text = truncateToLimit(text, maxChars);
warnings.push(
`Tweet truncated from ${weighted} to ${weightedCharCount(text)} chars (limit: ${maxChars}).`,
);
}
return {
text,
charCount: weightedCharCount(text),
wasTruncated,
warnings,
};
}
// ---------------------------------------------------------------------------
// Thread splitting
// ---------------------------------------------------------------------------
/**
* Split long-form content into a thread of tweets.
*
* Algorithm:
* 1. Split on paragraph boundaries
* 2. Each tweet <= 280 chars (including numbering)
* 3. First tweet = hook, last = CTA/summary
* 4. Each tweet should make sense standalone
*/
export function splitIntoThread(
content: string,
options: AdaptOptions = {},
): ThreadResult {
const maxChars = options.maxChars ?? TWEET_CHAR_LIMIT;
const addNumbering = options.addNumbering !== false;
const warnings: string[] = [];
// Strip Markdown
let text = stripMarkdown(content).trim();
// Split into paragraphs
const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0);
if (paragraphs.length === 0) {
return { tweets: [], totalTweets: 0, totalChars: 0, warnings: ['Empty content.'] };
}
// Estimate total tweets for numbering overhead
const estimatedTweets = Math.max(
Math.ceil(text.length / (maxChars - THREAD_NUMBER_OVERHEAD - 2)),
paragraphs.length,
);
const numberingLen = addNumbering
? ` (${estimatedTweets}/${estimatedTweets})`.length
: 0;
const effectiveMax = maxChars - numberingLen;
// Build tweets by fitting paragraphs
const tweets: string[] = [];
let currentTweet = '';
for (const para of paragraphs) {
const trimmedPara = para.trim();
if (weightedCharCount(trimmedPara) > effectiveMax) {
// Paragraph too long -- split by sentences
if (currentTweet) {
tweets.push(currentTweet.trim());
currentTweet = '';
}
const sentences = splitSentences(trimmedPara);
for (const sentence of sentences) {
const candidate = currentTweet
? currentTweet + ' ' + sentence
: sentence;
if (weightedCharCount(candidate) <= effectiveMax) {
currentTweet = candidate;
} else {
if (currentTweet) {
tweets.push(currentTweet.trim());
}
// If a single sentence exceeds limit, force-truncate
if (weightedCharCount(sentence) > effectiveMax) {
tweets.push(truncateToLimit(sentence, effectiveMax));
currentTweet = '';
warnings.push('A sentence exceeded the character limit and was truncated.');
} else {
currentTweet = sentence;
}
}
}
} else {
// Try to fit paragraph into current tweet
const candidate = currentTweet
? currentTweet + '\n\n' + trimmedPara
: trimmedPara;
if (weightedCharCount(candidate) <= effectiveMax) {
currentTweet = candidate;
} else {
// Current tweet is full, start new one
if (currentTweet) {
tweets.push(currentTweet.trim());
}
currentTweet = trimmedPara;
}
}
}
// Don't forget the last tweet
if (currentTweet.trim()) {
tweets.push(currentTweet.trim());
}
// Add hashtags to the last tweet if they fit
if (options.hashtags?.length && tweets.length > 0) {
const hashtagStr = options.hashtags
.slice(0, 2)
.map((h) => (h.startsWith('#') ? h : `#${h}`))
.join(' ');
const lastIdx = tweets.length - 1;
const candidate = tweets[lastIdx] + '\n\n' + hashtagStr;
if (weightedCharCount(candidate) + numberingLen <= maxChars) {
tweets[lastIdx] = candidate;
} else {
// Add as separate final tweet
tweets.push(hashtagStr);
}
}
// Add numbering
const total = tweets.length;
if (addNumbering && total > 1) {
for (let i = 0; i < total; i++) {
tweets[i] = `${tweets[i]}\n\n${i + 1}/${total}`;
}
}
// Validate all tweets are within limit
for (let i = 0; i < tweets.length; i++) {
const wc = weightedCharCount(tweets[i]);
if (wc > maxChars) {
warnings.push(`Tweet ${i + 1} is ${wc} chars (limit: ${maxChars}). May need manual editing.`);
}
}
const totalChars = tweets.reduce((sum, t) => sum + weightedCharCount(t), 0);
return {
tweets,
totalTweets: tweets.length,
totalChars,
warnings,
};
}
// ---------------------------------------------------------------------------
// Long-form article (X Premium)
// ---------------------------------------------------------------------------
/**
* Adapt for X long-form article (Premium feature, 25K chars).
*/
export function adaptLongForm(
content: string,
options: AdaptOptions = {},
): { text: string; charCount: number; wasTruncated: boolean; warnings: string[] } {
const maxChars = LONG_FORM_LIMIT;
const warnings: string[] = [];
// Long-form supports Markdown, so keep it
let text = content.trim();
// Add hashtags
if (options.hashtags?.length) {
const hashtagStr = options.hashtags
.map((h) => (h.startsWith('#') ? h : `#${h}`))
.join(' ');
text = text + '\n\n' + hashtagStr;
}
let wasTruncated = false;
if (text.length > maxChars) {
wasTruncated = true;
text = text.slice(0, maxChars - 3) + '...';
warnings.push(`Content truncated to ${maxChars} characters (X Premium long-form limit).`);
}
return {
text,
charCount: text.length,
wasTruncated,
warnings,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Strip Markdown syntax to plain text. */
function stripMarkdown(md: string): string {
let text = md;
// Remove headers
text = text.replace(/^#{1,6}\s+/gm, '');
// Bold/italic -> plain
text = text.replace(/\*\*(.+?)\*\*/g, '$1');
text = text.replace(/__(.+?)__/g, '$1');
text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1');
text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1');
// Inline code
text = text.replace(/`([^`]+)`/g, '$1');
// Code blocks
text = text.replace(/```[\s\S]*?```/g, '');
// Links: [text](url) -> text url
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 $2');
// Images
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '');
// Horizontal rules
text = text.replace(/^[-*_]{3,}$/gm, '');
// Blockquotes
text = text.replace(/^>\s?/gm, '');
// List markers
text = text.replace(/^[\s]*[-*]\s+/gm, '- ');
text = text.replace(/^[\s]*(\d+)\.\s+/gm, '$1. ');
// Collapse extra whitespace
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
/** Split text into sentences. */
function splitSentences(text: string): string[] {
// Split on sentence-ending punctuation followed by space
const parts = text.split(/(?<=[.!?])\s+/);
return parts.filter((s) => s.trim().length > 0);
}
/** Truncate text to fit within a character limit (weighted). */
function truncateToLimit(text: string, limit: number): string {
if (weightedCharCount(text) <= limit) return text;
// Try truncating at sentence boundary
const sentences = splitSentences(text);
let result = '';
for (const sentence of sentences) {
const candidate = result ? result + ' ' + sentence : sentence;
if (weightedCharCount(candidate) <= limit - 1) {
result = candidate;
} else {
break;
}
}
if (result) return result;
// Hard truncate
let truncated = text;
while (weightedCharCount(truncated + '...') > limit && truncated.length > 0) {
// Remove last word
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > 0) {
truncated = truncated.slice(0, lastSpace);
} else {
truncated = truncated.slice(0, truncated.length - 1);
}
}
return truncated + '...';
}
/**
* X (Twitter) publishing orchestrator via YouMind OpenAPI.
*
* Content adaptation (280-char clamp + thread splitting) lives here. The
* actual HTTP call is `createXPost` from `./x-api.js`. Threads are built
* natively by chaining each tweet as a reply to the previous one (via
* `replyToPostId`), so X renders the sequence as a proper thread.
*/
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
adaptSingleTweet,
splitIntoThread,
type AdaptOptions,
} from './content-adapter.js';
import {
createXPost,
loadXConfig,
uploadXMedia,
type XConfig,
type XPost,
} from './x-api.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_DIR = resolve(__dirname, '../..');
export interface PublishTweetOptions {
/** Raw text or Markdown content */
content: string;
/** Optional local image paths or cdn.gooo.ai URLs. Max 4. */
mediaUrls?: string[];
/** Custom hashtags (1-2 recommended) */
hashtags?: string[];
/** X config override */
config?: XConfig;
}
export interface PublishThreadOptions {
/** Raw text or Markdown content to split into a thread */
content: string;
/** Optional local image paths or cdn.gooo.ai URLs attached to the first tweet only. Max 4. */
mediaUrls?: string[];
/** Custom hashtags (added to last tweet) */
hashtags?: string[];
/** Add numbering (default: true) */
addNumbering?: boolean;
/** X config override */
config?: XConfig;
}
export interface PublishResult {
success: boolean;
type: 'tweet' | 'thread';
posts: XPost[];
content: string | string[];
warnings: string[];
error?: string;
}
const MIME_BY_EXTENSION = new Map<string, string>([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.png', 'image/png'],
['.gif', 'image/gif'],
['.webp', 'image/webp'],
]);
async function prepareMedia(
inputs: string[] | undefined,
config: XConfig,
): Promise<{ mediaUrls?: string[]; mediaIds?: string[] }> {
if (!inputs?.length) {
return {};
}
const mediaUrls: string[] = [];
const mediaIds: string[] = [];
for (const input of inputs) {
const remoteUrl = parseAnyRemoteUrl(input);
if (remoteUrl) {
if (remoteUrl.hostname === 'cdn.gooo.ai') {
mediaUrls.push(input);
}
continue;
}
const localPath = resolve(input);
if (!existsSync(localPath)) {
throw new Error(`Local image not found: ${localPath}`);
}
if (!statSync(localPath).isFile()) {
throw new Error(`Local image is not a file: ${localPath}`);
}
const filename = basename(localPath);
const contentType = inferMimeType(filename);
if (!contentType) {
throw new Error(
`Unsupported local image type for X upload: ${filename} (allowed: jpg, jpeg, png, gif, webp)`,
);
}
const uploaded = await uploadXMedia(config, {
filename,
contentBase64: readFileSync(localPath).toString('base64'),
contentType,
});
mediaIds.push(uploaded.mediaId);
}
return {
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
mediaIds: mediaIds.length > 0 ? mediaIds : undefined,
};
}
function parseAnyRemoteUrl(input: string): URL | null {
try {
return new URL(input);
} catch {
return null;
}
}
function collectRemoteWarnings(inputs: string[] | undefined, warnings: string[]): void {
if (!inputs?.length) return;
for (const input of inputs) {
try {
const parsed = new URL(input);
if (parsed.hostname !== 'cdn.gooo.ai') {
warnings.push(
`Skipped media URL not under cdn.gooo.ai: ${input} (use a local file path or upload to YouMind CDN first)`,
);
}
} catch {
// 本地路径由 prepareMedia 继续处理,不在这里报 warning
}
}
}
function inferMimeType(filename: string): string | undefined {
return MIME_BY_EXTENSION.get(extname(filename).toLowerCase());
}
export async function publishTweet(
options: PublishTweetOptions,
): Promise<PublishResult> {
const config = options.config ?? loadXConfig();
if (!config.apiKey) {
return {
success: false,
type: 'tweet',
posts: [],
content: options.content,
warnings: [],
error: 'youmind.api_key not set. Configure ~/.youmind/config.yaml.',
};
}
const adapted = adaptSingleTweet(options.content, { hashtags: options.hashtags });
collectRemoteWarnings(options.mediaUrls, adapted.warnings);
try {
const media = await prepareMedia(options.mediaUrls, config);
const post = await createXPost(config, {
text: adapted.text,
mediaUrls: media.mediaUrls,
mediaIds: media.mediaIds,
});
saveOutput('tweet', { postId: post.postId, text: adapted.text, url: post.url });
return {
success: true,
type: 'tweet',
posts: [post],
content: adapted.text,
warnings: adapted.warnings,
};
} catch (err) {
return {
success: false,
type: 'tweet',
posts: [],
content: adapted.text,
warnings: adapted.warnings,
error: (err as Error).message,
};
}
}
export async function publishThread(
options: PublishThreadOptions,
): Promise<PublishResult> {
const config = options.config ?? loadXConfig();
if (!config.apiKey) {
return {
success: false,
type: 'thread',
posts: [],
content: [],
warnings: [],
error: 'youmind.api_key not set. Configure ~/.youmind/config.yaml.',
};
}
const thread = splitIntoThread(options.content, {
hashtags: options.hashtags,
addNumbering: options.addNumbering,
});
if (thread.tweets.length === 0) {
return {
success: false,
type: 'thread',
posts: [],
content: [],
warnings: ['No content to post.'],
error: 'Empty content',
};
}
if (thread.tweets.length === 1) {
const result = await publishTweet({
content: thread.tweets[0],
mediaUrls: options.mediaUrls,
config,
});
return {
...result,
type: 'thread',
content: [typeof result.content === 'string' ? result.content : thread.tweets[0]],
};
}
const warnings = [...thread.warnings];
collectRemoteWarnings(options.mediaUrls, warnings);
const posts: XPost[] = [];
try {
const firstTweetMedia = await prepareMedia(options.mediaUrls, config);
// Chain each tweet to the previous one so X renders the sequence as a
// native thread instead of independent standalone tweets.
let previousPostId: string | undefined;
for (let i = 0; i < thread.tweets.length; i++) {
try {
const post = await createXPost(config, {
text: thread.tweets[i],
mediaUrls: i === 0 ? firstTweetMedia.mediaUrls : undefined,
mediaIds: i === 0 ? firstTweetMedia.mediaIds : undefined,
replyToPostId: previousPostId,
});
posts.push(post);
previousPostId = post.postId;
console.log(`Thread ${i + 1}/${thread.tweets.length}: ${post.postId}`);
} catch (err) {
return {
success: false,
type: 'thread',
posts,
content: thread.tweets,
warnings,
error: `Tweet ${i + 1}/${thread.tweets.length} failed: ${(err as Error).message}`,
};
}
}
} catch (err) {
return {
success: false,
type: 'thread',
posts,
content: thread.tweets,
warnings,
error: (err as Error).message,
};
}
saveOutput('thread', {
postIds: posts.map((p) => p.postId),
tweets: thread.tweets,
urls: posts.map((p) => p.url),
warnings,
});
return {
success: true,
type: 'thread',
posts,
content: thread.tweets,
warnings,
};
}
export function previewTweet(
content: string,
options: AdaptOptions = {},
): { text: string; charCount: number; warnings: string[] } {
const adapted = adaptSingleTweet(content, options);
return {
text: adapted.text,
charCount: adapted.charCount,
warnings: adapted.warnings,
};
}
export function previewThread(
content: string,
options: AdaptOptions = {},
): { tweets: string[]; totalTweets: number; warnings: string[] } {
const thread = splitIntoThread(content, options);
return {
tweets: thread.tweets,
totalTweets: thread.totalTweets,
warnings: thread.warnings,
};
}
function saveOutput(type: string, data: Record<string, unknown>): void {
try {
const outputDir = resolve(PROJECT_DIR, 'output');
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const outputPath = resolve(outputDir, `x-${type}-${timestamp}.json`);
writeFileSync(
outputPath,
JSON.stringify({ ...data, timestamp: new Date().toISOString() }, null, 2),
);
} catch {
// Non-critical, ignore
}
}
/**
* X (Twitter) client via YouMind OpenAPI (aggregated publishing endpoints).
*
* 后端统一在 6 个 resource 端点(/publishing/{connections,posts,media,engagement,taxonomy,insights}),
* body = { platform, action, [actionKey]: payload },platform=x 通过 discriminated union 区分。
* 所有响应统一为 { platform, data },本层自动解嵌套返回 data。
* 旧 op 名(createPost / uploadMedia / deletePost)经 buildPublishingRequest 映射到对应端点与 action。
*/
import { loadYouMindConfig, YOUMIND_CONFIG_ERROR_HINT } from './config.js';
export interface XConfig {
apiKey: string;
baseUrl: string;
}
export interface CreateXPostOptions {
text: string;
/** Optional image URLs — must be publicly reachable https URLs under cdn.gooo.ai. Max 4. */
mediaUrls?: string[];
/** Optional pre-uploaded X media IDs returned by uploadXMedia. Max 4. */
mediaIds?: string[];
/**
* Optional tweet ID this post replies to. Used to build threads — publish the first tweet,
* then pass its `postId` here for each subsequent tweet so X renders the chain natively.
*/
replyToPostId?: string;
}
export interface XPost {
postId: string;
text: string;
url: string;
}
export interface DeleteXPostResult {
ok: boolean;
postId: string;
}
export interface UploadXMediaOptions {
filename: string;
contentBase64: string;
contentType?: string;
}
export interface UploadedXMedia {
mediaId: string;
}
interface OpenApiErrorDetail {
connectUrl?: string;
upgradeUrl?: string;
hint?: string;
}
interface OpenApiErrorResponse {
message?: string;
code?: string;
detail?: OpenApiErrorDetail;
}
export function loadXConfig(): XConfig {
const { apiKey, baseUrl } = loadYouMindConfig();
return {
apiKey,
baseUrl,
};
}
async function postJson<T = unknown>(
endpoint: string,
body: Record<string, unknown> = {},
config?: XConfig,
): Promise<T> {
const cfg = config ?? loadXConfig();
if (!cfg.apiKey) {
throw new Error(`YouMind API key not configured. ${YOUMIND_CONFIG_ERROR_HINT}`);
}
const response = await fetch(`${cfg.baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': cfg.apiKey,
'x-use-camel-case': 'true',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const parsed = parseOpenApiError(text);
throw new Error(
`YouMind X API ${endpoint} failed (${response.status})` +
`: ${formatOpenApiError(parsed, text)}`,
);
}
return response.json() as Promise<T>;
}
// ─── 6-endpoint adapter: 后端把 26 个 publishing op 合并成 6 个 resource 端点 ───
// 每个端点 body = { platform, action, [actionKey]: <payload> };insights 无 action(payload 平铺)。
const PUBLISHING_OP_MAP: Record<
string,
{ route: string; action: string | null; key: string | null }
> = {
// connections
listConnections: { route: 'connections', action: 'list', key: 'list' },
validateConnection: { route: 'connections', action: 'validate', key: null },
disconnect: { route: 'connections', action: 'disconnect', key: 'disconnect' },
authenticate: { route: 'connections', action: 'authenticate', key: 'authenticate' },
getCredentials: { route: 'connections', action: 'getCredentials', key: null },
// posts
createPost: { route: 'posts', action: 'create', key: 'create' },
updatePost: { route: 'posts', action: 'update', key: 'update' },
getPost: { route: 'posts', action: 'get', key: 'get' },
listPosts: { route: 'posts', action: 'list', key: 'list' },
deletePost: { route: 'posts', action: 'delete', key: 'delete' },
transitionPostState: { route: 'posts', action: 'transition', key: 'transition' },
getPublishJob: { route: 'posts', action: 'getJob', key: 'getJob' },
manageQueue: { route: 'posts', action: 'manageQueue', key: 'manageQueue' },
// media
uploadMedia: { route: 'media', action: 'upload', key: 'upload' },
listMedia: { route: 'media', action: 'list', key: 'list' },
deleteMedia: { route: 'media', action: 'delete', key: 'delete' },
// engagement
listEngagement: { route: 'engagement', action: 'list', key: 'list' },
upsertEngagement: { route: 'engagement', action: 'upsert', key: 'upsert' },
deleteEngagement: { route: 'engagement', action: 'delete', key: 'delete' },
listSocial: { route: 'engagement', action: 'listSocial', key: 'listSocial' },
setSocialAction: { route: 'engagement', action: 'setSocialAction', key: 'setSocialAction' },
// taxonomy
listTaxonomy: { route: 'taxonomy', action: 'list', key: 'list' },
upsertTaxonomy: { route: 'taxonomy', action: 'upsert', key: 'upsert' },
deleteTaxonomy: { route: 'taxonomy', action: 'delete', key: 'delete' },
attachPostToTaxonomy: { route: 'taxonomy', action: 'attachPost', key: 'attachPost' },
// insights(单操作,无 action 区分符)
getInsights: { route: 'insights', action: null, key: null },
};
// 把旧的 { platform, ...payload } 调用重塑成新的 6-端点 body:{ platform, action, [key]: rest }。
function buildPublishingRequest(
op: string,
payload: Record<string, unknown>,
): { route: string; body: Record<string, unknown> } {
const mapping = PUBLISHING_OP_MAP[op];
if (!mapping) {
throw new Error('Unknown publishing op: ' + op);
}
const { platform, ...rest } = payload;
if (mapping.action === null) {
// insights:scope / postId / dateRange 平铺,无 action
return { route: mapping.route, body: { platform, ...rest } };
}
if (mapping.key === null) {
// validate / getCredentials:仅需 platform + action,无 sub-payload
return { route: mapping.route, body: { platform, action: mapping.action } };
}
return { route: mapping.route, body: { platform, action: mapping.action, [mapping.key]: rest } };
}
// 聚合层调用:包一层自动从 { platform, data } 解出 data,对外保持旧接口形状
async function callPublishing<T = unknown>(
op: string,
payload: Record<string, unknown>,
config?: XConfig,
): Promise<T> {
const { route, body } = buildPublishingRequest(op, payload);
const wrapped = await postJson<{ platform: string; data: T }>(
`/publishing/${route}`,
body,
config,
);
return wrapped.data;
}
function parseOpenApiError(text: string): OpenApiErrorResponse | null {
try {
return JSON.parse(text) as OpenApiErrorResponse;
} catch {
return null;
}
}
function formatOpenApiError(parsed: OpenApiErrorResponse | null, rawText: string): string {
if (!parsed) {
return rawText.slice(0, 300);
}
const parts = [parsed.message, parsed.code, parsed.detail?.hint].filter(
(value): value is string => typeof value === 'string' && value.length > 0,
);
if (parsed.detail?.connectUrl) {
parts.push(`Connect X: ${parsed.detail.connectUrl}`);
}
if (parsed.detail?.upgradeUrl) {
parts.push(`Upgrade plan: ${parsed.detail.upgradeUrl}`);
}
return parts.join(' | ') || rawText.slice(0, 300);
}
function normalizePost(raw: Record<string, unknown>): XPost {
return {
postId: String(raw.postId ?? raw.post_id ?? ''),
text: String(raw.text ?? ''),
url: String(raw.url ?? ''),
};
}
/**
* Publish a single tweet via the YouMind proxy.
*
* `options.mediaUrls` must be publicly reachable https URLs under cdn.gooo.ai
* — the YouMind backend enforces this allowlist to avoid SSRF. Non-cdn URLs
* will be rejected with `X_MEDIA_HOST_NOT_ALLOWED`.
*
* `options.replyToPostId` chains this tweet as a reply to an existing one.
* That is how threads are built: publish the first tweet, then for each
* subsequent tweet in the sequence pass the previous tweet's `postId` here.
*/
export async function createXPost(
config: XConfig,
options: CreateXPostOptions,
): Promise<XPost> {
const post: Record<string, unknown> = {
content: { format: 'plain', body: options.text },
};
if (options.mediaUrls?.length) post.mediaUrls = options.mediaUrls;
if (options.mediaIds?.length) post.mediaIds = options.mediaIds;
if (options.replyToPostId) post.replyToPostId = options.replyToPostId;
const data = await callPublishing<Record<string, unknown>>(
'createPost',
{ platform: 'x', post },
config,
);
return normalizePost(data);
}
export async function uploadXMedia(
config: XConfig,
options: UploadXMediaOptions,
): Promise<UploadedXMedia> {
const media: Record<string, unknown> = {
kind: 'image',
filename: options.filename,
source: { base64: options.contentBase64 },
};
if (options.contentType) media.contentType = options.contentType;
const data = await callPublishing<Record<string, unknown>>(
'uploadMedia',
{ platform: 'x', media },
config,
);
return { mediaId: String(data.mediaId ?? data.media_id ?? '') };
}
export async function deleteXPost(
config: XConfig,
postId: string,
): Promise<DeleteXPostResult> {
const data = await callPublishing<Record<string, unknown>>(
'deletePost',
{ platform: 'x', postId },
config,
);
return {
ok: Boolean(data.ok ?? true),
postId: String(data.postId ?? data.post_id ?? postId),
};
}
/**
* YouMind OpenAPI client — knowledge mining, search, web search, and article archiving.
*
* Usage (CLI):
* npx tsx src/youmind-api.ts search "AI 大模型" --top-k 10
* npx tsx src/youmind-api.ts web-search "今日AI热点" --freshness day
* npx tsx src/youmind-api.ts list-boards
* npx tsx src/youmind-api.ts list-materials <board_id>
* npx tsx src/youmind-api.ts list-crafts <board_id>
* npx tsx src/youmind-api.ts get-material <id>
* npx tsx src/youmind-api.ts get-craft <id>
* npx tsx src/youmind-api.ts save-article <board_id> --title "..." --file article.md
* npx tsx src/youmind-api.ts mine-topics "AI,产品设计" --board <board_id> --top-k 5
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadYouMindConfig, YOUMIND_CONFIG_ERROR_HINT } from './config.js';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
interface YouMindConfig {
apiKey: string;
baseUrl: string;
}
function loadConfig(): YouMindConfig {
const { apiKey, baseUrl } = loadYouMindConfig();
return {
apiKey,
baseUrl,
};
}
// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------
async function post<T = unknown>(
endpoint: string,
body: Record<string, unknown> = {},
config?: YouMindConfig,
): Promise<T> {
const cfg = config ?? loadConfig();
if (!cfg.apiKey) {
throw new Error(`YouMind API key not configured. ${YOUMIND_CONFIG_ERROR_HINT}`);
}
const resp = await fetch(`${cfg.baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': cfg.apiKey,
},
body: JSON.stringify(body),
// createChat 需要等 AI 响应,给 120s;其他 API 15s 足够
signal: AbortSignal.timeout(endpoint.includes('Chat') || endpoint.includes('Message') ? 120_000 : 15_000),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`YouMind API ${endpoint} 失败 (${resp.status}): ${text.slice(0, 300)}`);
}
return resp.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Public API — Search
// ---------------------------------------------------------------------------
export interface SearchResult {
entity_id?: string;
entity_type?: string;
metadata?: { title?: string; content?: string; [k: string]: unknown };
// normalized fields (filled by search())
id?: string;
title?: string;
content?: string;
type?: string;
score?: number;
[key: string]: unknown;
}
export interface SearchResponse {
results: SearchResult[];
[key: string]: unknown;
}
export interface SearchOptions {
query: string;
topK?: number;
filterTypes?: ('article' | 'note' | 'page')[];
filterSourceIds?: string[];
filterFields?: ('title' | 'content')[];
filterUpdatedAt?: { from?: number; to?: number };
}
export async function search(opts: SearchOptions, config?: YouMindConfig): Promise<SearchResponse> {
const body: Record<string, unknown> = { query: opts.query, scope: 'library' };
if (opts.topK) body.top_k = opts.topK;
if (opts.filterTypes) body.filter_types = opts.filterTypes;
if (opts.filterSourceIds) body.filter_source_ids = opts.filterSourceIds;
if (opts.filterFields) body.filter_fields = opts.filterFields;
if (opts.filterUpdatedAt) body.filter_updated_at = opts.filterUpdatedAt;
const raw = await post<SearchResponse>('/search', body, config);
// Normalize: API returns entity_id/entity_type/metadata, map to flat fields
if (raw.results) {
for (const r of raw.results) {
r.id = r.id ?? r.entity_id;
r.type = r.type ?? r.entity_type;
r.title = r.title ?? r.metadata?.title;
r.content = r.content ?? r.metadata?.content;
}
}
return raw;
}
// ---------------------------------------------------------------------------
// Public API — Web Search
// ---------------------------------------------------------------------------
export interface WebSearchResult {
title: string;
url: string;
snippet: string;
date_published?: string | null;
[key: string]: unknown;
}
export interface WebSearchResponse {
results: WebSearchResult[];
formatted_context?: string | null;
total_results?: number | null;
[key: string]: unknown;
}
export interface WebSearchOptions {
query: string;
freshness?: 'day' | 'week' | 'month' | 'year';
includeDomains?: string[];
excludeDomains?: string[];
}
export async function webSearch(opts: WebSearchOptions, config?: YouMindConfig): Promise<WebSearchResponse> {
const body: Record<string, unknown> = { query: opts.query };
if (opts.freshness) body.freshness = opts.freshness;
if (opts.includeDomains) body.include_domains = opts.includeDomains;
if (opts.excludeDomains) body.exclude_domains = opts.excludeDomains;
return post<WebSearchResponse>('/webSearch', body, config);
}
// ---------------------------------------------------------------------------
// Public API — Boards
// ---------------------------------------------------------------------------
export interface Board {
id: string;
name: string;
type?: string;
count?: number;
[key: string]: unknown;
}
export async function listBoards(config?: YouMindConfig): Promise<Board[]> {
return post<Board[]>('/listBoards', {}, config);
}
export async function getBoard(id: string, config?: YouMindConfig): Promise<Board> {
return post<Board>('/getBoard', { id }, config);
}
// ---------------------------------------------------------------------------
// Public API — Materials
// ---------------------------------------------------------------------------
export interface Material {
id: string;
title?: string;
content?: string;
type?: string;
board_id?: string;
url?: string;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
export async function listMaterials(boardId: string, groupId?: string, config?: YouMindConfig): Promise<Material[]> {
const body: Record<string, unknown> = { board_id: boardId };
if (groupId) body.group_id = groupId;
return post<Material[]>('/listMaterials', body, config);
}
export async function getMaterial(id: string, config?: YouMindConfig): Promise<Material> {
return post<Material>('/getMaterial', { id }, config);
}
// ---------------------------------------------------------------------------
// Public API — Crafts (Documents)
// ---------------------------------------------------------------------------
export interface Craft {
id: string;
title?: string;
content?: string;
type?: string;
board_id?: string;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
export async function listCrafts(boardId: string, groupId?: string, config?: YouMindConfig): Promise<Craft[]> {
const body: Record<string, unknown> = { board_id: boardId };
if (groupId) body.group_id = groupId;
return post<Craft[]>('/listCrafts', body, config);
}
export async function getCraft(id: string, config?: YouMindConfig): Promise<Craft> {
return post<Craft>('/getCraft', { id }, config);
}
// ---------------------------------------------------------------------------
// Public API — Save article to YouMind
// ---------------------------------------------------------------------------
export interface SavedDocument {
id: string;
title: string;
board_id: string;
[key: string]: unknown;
}
export async function saveArticle(
boardId: string,
title: string,
markdownContent: string,
config?: YouMindConfig,
): Promise<SavedDocument> {
return post<SavedDocument>('/createDocumentByMarkdown', {
board_id: boardId,
title,
content: markdownContent,
}, config);
}
// ---------------------------------------------------------------------------
// Public API — Knowledge Mining (composite)
// ---------------------------------------------------------------------------
export interface MinedContent {
source: 'search' | 'material' | 'craft';
id: string;
title: string;
snippet: string;
relevance?: number;
updatedAt?: string;
}
export interface MineTopicsOptions {
topics: string[];
boardIds?: string[];
topK?: number;
}
/**
* 从用户的 YouMind 知识库中挖掘与选题相关的素材。
* 组合语义搜索 + board 浏览,返回去重后的相关内容摘要。
*/
export async function mineTopics(opts: MineTopicsOptions, config?: YouMindConfig): Promise<MinedContent[]> {
const cfg = config ?? loadConfig();
const results: MinedContent[] = [];
const seenIds = new Set<string>();
const topK = opts.topK ?? 5;
// 1. 对每个 topic 做语义搜索
const searchPromises = opts.topics.map(topic =>
search({ query: topic, topK, filterTypes: ['article', 'note', 'page'] }, cfg)
.catch(e => { console.error(`搜索 "${topic}" 失败:`, e.message); return null; })
);
const searchResults = await Promise.all(searchPromises);
for (const res of searchResults) {
if (!res?.results) continue;
for (const item of res.results) {
const id = item.id ?? '';
if (!id || seenIds.has(id)) continue;
seenIds.add(id);
results.push({
source: 'search',
id,
title: item.title ?? '(无标题)',
snippet: String(item.content ?? '').slice(0, 300),
relevance: item.score,
updatedAt: item.updated_at as string | undefined,
});
}
}
// 2. 浏览指定 board 的最新内容
if (opts.boardIds?.length) {
const boardPromises = opts.boardIds.flatMap(bid => [
listMaterials(bid, undefined, cfg).catch(() => [] as Material[]),
listCrafts(bid, undefined, cfg).catch(() => [] as Craft[]),
]);
const boardResults = await Promise.all(boardPromises);
for (const items of boardResults) {
if (!Array.isArray(items)) continue;
for (const item of items.slice(0, 20)) {
const id = item.id ?? '';
if (!id || seenIds.has(id)) continue;
seenIds.add(id);
const isCraft = 'board_id' in item && ('type' in item && (item as Craft).type === 'page');
results.push({
source: isCraft ? 'craft' : 'material',
id,
title: item.title ?? '(无标题)',
snippet: String(item.content ?? '').slice(0, 300),
updatedAt: item.updated_at as string | undefined,
});
}
}
}
return results;
}
// ---------------------------------------------------------------------------
// Public API — Chat-based Image Generation
// ---------------------------------------------------------------------------
export interface ChatImageResult {
chatId: string;
imageUrls: string[];
text: string;
}
/**
* 通过 YouMind Chat API (agent 模式) AI 生图。
* 流程: createChat(agent) → agent 自动加载 imageGenerate 工具并生图
* → 轮询 listMessages 等待 cdn.gooo.ai 图片 URL 出现。
*/
export async function chatGenerateImage(
prompt: string, config?: YouMindConfig,
): Promise<ChatImageResult> {
const cfg = config ?? loadConfig();
// Step 1: createChat 以 agent 模式启动
const createResp = await post<Record<string, unknown>>('/createChat', {
message: `请加载生图工具并生成一张图片:${prompt}`,
message_mode: 'agent',
}, cfg);
const chatId = (createResp.id as string) ?? '';
if (!chatId) throw new Error('createChat 未返回 chat_id');
// 先检查 createChat 响应是否已经包含生成的图
const initial = extractImages(createResp);
if (initial.urls.length) {
return { chatId, imageUrls: initial.urls, text: '' };
}
// Step 2: 轮询 listMessages 等待图片生成完成(最多 120 秒)
const maxWait = 120_000;
const interval = 3_000;
const start = Date.now();
let lastToolErrors: string[] = [];
while (Date.now() - start < maxWait) {
await new Promise(r => setTimeout(r, interval));
const msgResp = await post<Record<string, unknown>>('/listMessages', { chat_id: chatId }, cfg);
const extracted = extractImages(msgResp);
if (extracted.urls.length) {
return { chatId, imageUrls: extracted.urls, text: '' };
}
lastToolErrors = extracted.toolErrors;
// 检查 agent 是否已结束(所有 message status != pending)
const messages = (msgResp.messages ?? []) as Record<string, unknown>[];
const lastAst = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAst && lastAst.status === 'success') {
// agent 已完成但没有生成图片
break;
}
}
// tool 自报错误优先(如 "No image was generated. Please change your prompt and try again.")
if (lastToolErrors.length) {
throw new Error(`YouMind generateImage 未生成图片: ${lastToolErrors.join('; ')}`);
}
throw new Error('YouMind AI 生图超时或未生成图片');
}
interface ExtractedImages {
urls: string[];
/** generateImage 工具自报的错误(如 "No image was generated..."),用于把真实失败原因透传给上层 */
toolErrors: string[];
}
/**
* 从 listMessages / createChat 响应里提取 generateImage 工具产物。
*
* 优先走稳定契约 `tool_result.files[].file.{original_url,compressed_url}`,
* 而不是在响应 JSON 文本里正则扫 URL —— `toolResponse` 是给 LLM 看的文案,
* youapi 端会按 LLM 引导反复调整其格式(如 commit 8df310601d / 1d54fe75e3)。
* 仅当结构化路径找不到时,才回退到正则兜底,确保旧 schema 也能工作。
*/
function extractImages(resp: Record<string, unknown>): ExtractedImages {
const urls: string[] = [];
const toolErrors: string[] = [];
const seen = new Set<string>();
const messages = (resp.messages ?? []) as Record<string, unknown>[];
for (const msg of messages) {
if ((msg.role as string) !== 'assistant') continue;
const blocks = (msg.blocks ?? []) as Record<string, unknown>[];
for (const block of blocks) {
if ((block.type as string) !== 'tool') continue;
const toolName = block.tool_name as string | undefined;
// 兼容 camelCase / snake_case 两种命名
if (toolName !== 'generateImage' && toolName !== 'generate_image') continue;
const toolResult = (block.tool_result ?? {}) as Record<string, unknown>;
const filesRaw = (toolResult.files as unknown[]) ?? [];
const files = filesRaw.filter((f): f is Record<string, unknown> => !!f && typeof f === 'object');
const fileList = files.length === 0 && toolResult.file
? [toolResult.file as Record<string, unknown>]
: files;
if (fileList.length === 0) {
// tool 显式返回 files=[],说明生成失败;把 toolResponse 当真实错误透出
const errText = block.tool_response as string | undefined;
if (errText) toolErrors.push(errText);
continue;
}
for (const fileDto of fileList) {
const fileMeta = (fileDto.file ?? {}) as Record<string, unknown>;
const url = (fileMeta.original_url ?? fileMeta.compressed_url) as string | undefined;
if (url && !seen.has(url)) { seen.add(url); urls.push(url); }
}
}
}
// 兜底:旧版 schema 或 toolResponse 仍带 URL 的情况
if (urls.length === 0) {
const raw = JSON.stringify(resp);
for (const m of raw.matchAll(/https?:\/\/cdn\.gooo\.ai\/gen-images\/[a-f0-9]+\.(?:jpg|jpeg|png|webp)/gi)) {
if (!seen.has(m[0])) { seen.add(m[0]); urls.push(m[0]); }
}
}
return { urls, toolErrors };
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function cli() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help') {
console.log(`YouMind API CLI
Commands:
search <query> [--top-k N] [--types article,note,page] [--board <id>]
web-search <query> [--freshness day|week|month|year]
list-boards
list-materials <board_id>
list-crafts <board_id>
get-material <id>
get-craft <id>
save-article <board_id> --title "..." --file article.md
mine-topics "topic1,topic2" [--board <id>] [--top-k N]
generate-image "prompt description"`);
return;
}
const getArg = (flag: string): string | undefined => {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
};
const output = (data: unknown) => console.log(JSON.stringify(data, null, 2));
switch (command) {
case 'search': {
const query = args[1];
if (!query) { console.error('缺少 query 参数'); process.exit(1); }
const topK = parseInt(getArg('--top-k') ?? '10', 10);
const types = getArg('--types')?.split(',') as ('article' | 'note' | 'page')[] | undefined;
const boardId = getArg('--board');
const res = await search({
query, topK, filterTypes: types,
filterSourceIds: boardId ? [boardId] : undefined,
});
output(res);
break;
}
case 'web-search': {
const query = args[1];
if (!query) { console.error('缺少 query 参数'); process.exit(1); }
const freshness = getArg('--freshness') as WebSearchOptions['freshness'];
const res = await webSearch({ query, freshness });
output(res);
break;
}
case 'list-boards': {
output(await listBoards());
break;
}
case 'list-materials': {
const boardId = args[1];
if (!boardId) { console.error('缺少 board_id 参数'); process.exit(1); }
output(await listMaterials(boardId));
break;
}
case 'list-crafts': {
const boardId = args[1];
if (!boardId) { console.error('缺少 board_id 参数'); process.exit(1); }
output(await listCrafts(boardId));
break;
}
case 'get-material': {
const id = args[1];
if (!id) { console.error('缺少 id 参数'); process.exit(1); }
output(await getMaterial(id));
break;
}
case 'get-craft': {
const id = args[1];
if (!id) { console.error('缺少 id 参数'); process.exit(1); }
output(await getCraft(id));
break;
}
case 'save-article': {
const boardId = args[1];
const title = getArg('--title');
const file = getArg('--file');
if (!boardId || !title || !file) {
console.error('用法: save-article <board_id> --title "..." --file article.md');
process.exit(1);
}
const content = readFileSync(resolve(process.cwd(), file), 'utf-8');
output(await saveArticle(boardId, title, content));
break;
}
case 'mine-topics': {
const topicsStr = args[1];
if (!topicsStr) { console.error('缺少 topics 参数 (逗号分隔)'); process.exit(1); }
const topics = topicsStr.split(',').map(s => s.trim()).filter(Boolean);
const boardId = getArg('--board');
const topK = parseInt(getArg('--top-k') ?? '5', 10);
const res = await mineTopics({
topics,
boardIds: boardId ? [boardId] : undefined,
topK,
});
output(res);
break;
}
case 'generate-image': {
const prompt = args[1];
if (!prompt) { console.error('缺少 prompt 参数'); process.exit(1); }
const res = await chatGenerateImage(prompt);
output(res);
break;
}
default:
console.error(`未知命令: ${command}`);
process.exit(1);
}
}
// Run CLI if invoked directly
const isMain = process.argv[1]?.endsWith('youmind-api.ts') ||
process.argv[1]?.endsWith('youmind-api.js');
if (isMain) {
cli().catch(e => { console.error(e.message); process.exit(1); });
}
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}