Compare commits

...

8 Commits

Author SHA1 Message Date
wangruofeng c9a50cc908 fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题 (#189)
* fix(baoyu-post-to-x): render CJK-adjacent bold/italics in articles

md-to-html.ts preprocessed markdown with remark + remark-cjk-friendly, then re-stringified and re-parsed with marked. remark-cjk-friendly parses emphasis adjacent to CJK correctly, but remark-stringify re-emits the **bold*/*italic* markers, which marked then FAILS to parse: its flanking test does not treat a CJK character after the closing delimiter as valid punctuation/whitespace. Result: literal **...** asterisks leak into the pasted X Article body whenever the closing delimiter is directly followed by a CJK character.

Fix: convert strong/emphasis mdast nodes (already identified correctly by remark-cjk-friendly) into raw inline <strong>/<em> HTML before stringify, so marked passes them through untouched. A small recursive serializer preserves nested links, inline code, images, etc.

- add remarkStrongEmToHtml remark plugin + inline mdast serializer
- add unist-util-visit as an explicit dependency
- add regression test covering CJK-adjacent bold and italics

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题

按维护者建议改用 remark-cjk-friendly 标准用法,替代自定义插件/序列化器:

- 删除 remarkStrongEmToHtml 插件与 serializeMdastNode 序列化器
- preprocessCjkMarkdown 简化为 remarkParse + remarkCjkFriendly + remarkStringify 往返
- 移除原 markdown 阶段的 &#x...; 实体解码(该解码会还原 cjk-friendly 为
  骗过 marked flanking 判定而生成的实体,正是 CJK 加粗失败的根因)
- 改在 marked 渲染出最终 HTML 后再 decodeHtmlEntities,此时 markdown 语法
  已不存在,解码安全且保持输出干净
- 移除不再使用的 unist-util-visit 显式依赖
- 测试补充引用式链接 **[docs][d]** 在强调内的回归断言(顺带修复 Codex
  指出的 linkReference 渲染为纯文本的 P2 问题)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 移除渲染后无差别实体解码,避免篡改作者字面实体

回应 Codex review (P2):decodeHtmlEntities 对整个渲染 HTML 做无差别
&#x...; 解码,会把作者为显示字面 HTML 而写的实体(如 &#x3C;b&#x3E;)
解码成真实标签,存在内容篡改与 HTML 注入风险。

该解码本就只是为了让输出 HTML「好看」,而 remark-cjk-friendly 引入的
实体是合法 HTML 字符引用,粘进 X 编辑器时能正确渲染;且这些实体只会
出现在强调 span 的外侧(边界字符),不会出现在 span 内部。因此直接
移除解码,既消除过度解码风险,又无功能损失。

- 删除 decodeHtmlEntities 及其在 convertMarkdownToHtml 的调用
- 新增测试:作者字面实体 &#x3C;b&#x3E; 不被解码为真实 <b>,CJK 加粗
  仍正常渲染
- bun test 8 个用例全部通过;真实文章端到端验证 17 处加粗、0 字面 **、
  0 真实标签注入

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: ruofeng <ruofeng.wang@rd.group>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-28 01:42:07 -05:00
Jim Liu 宝玉 348dc82612 Add explicit MIT license 2026-06-19 23:01:55 -05:00
Jim Liu 宝玉 dba3d732c3 Merge pull request #187 from jiji262/fix/gemini3-image-extraction
fix(gemini-web): recover generated images on Gemini 3
2026-06-19 22:45:31 -05:00
jiji262 661bdd08ff fix(gemini-web): recover generated images on Gemini 3
Gemini 3 stopped emitting the legacy `image_generation_content` marker in
the candidate text and moved the generated image into a later response
part. The `wants_generated` gate only checked the candidate text / a single
structured field, so on Gemini 3 it was never true and the image was
silently dropped: generate_content returned an empty result even though the
gg-dl image URL was present in the raw response.

- Broaden `wants_generated` to also fire when the raw response contains a
  `gg-dl/` generated-image URL or an `image_generation_content` marker.
- When a candidate has no image part, skip extraction instead of throwing
  ImageGenerationError, so multi-candidate responses don't fail wholesale.

Verified against a live Gemini 3 account: image generation returns the
generated image again with the default gemini-3-pro model.
2026-06-20 11:37:12 +08:00
Jim Liu 宝玉 f490f7e5e3 chore: release v2.5.2 2026-06-18 14:14:54 -05:00
Jim Liu 宝玉 22031aa2d2 docs(project): document Codex project-level install and WeChat credential scopes 2026-06-18 14:13:43 -05:00
Jim Liu 宝玉 51b31aa050 fix(baoyu-image-gen): require real image_gen evidence in codex-imagegen
Stop Codex from satisfying a request by copying an unrelated pre-existing
image from generated_images instead of generating a new one. Verification
now requires a PNG in this thread's generated_images dir (or an image_gen
stream item, kept as a forward-compatible signal), and the spawned-agent
instruction forbids reading or reusing history images before image_gen is
called. Removes the findCpToTarget fallback that allowed the shortcut.

Validated against a real codex exec run: image_gen leaves no stream item,
so the filesystem check is the load-bearing signal; locked in via a
condensed real-stream regression fixture.

Closes #185
2026-06-18 14:13:43 -05:00
Jim Liu 宝玉 441ca307a6 chore(release): sync ClawHub skill versions 2026-06-13 00:00:38 -05:00
24 changed files with 301 additions and 80 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "2.5.1"
"version": "2.5.2"
},
"plugins": [
{
+8
View File
@@ -2,6 +2,14 @@
English | [中文](./CHANGELOG.zh.md)
## 2.5.2 - 2026-06-18
### Fixes
- `codex-imagegen`: stop Codex from satisfying a request by copying an unrelated pre-existing image from `generated_images` instead of generating a new one. Verification now requires real evidence that `image_gen` ran in the current thread — an `image_gen` event in the stream **or** a PNG in this thread's `generated_images` dir — and the spawned-agent instruction forbids reading or reusing history images before `image_gen` is called ([#185](https://github.com/JimLiu/baoyu-skills/issues/185))
### Documentation
- README: document Codex project-level install (`.agents/skills`) and project- vs user-level WeChat credential scopes
## 2.5.1 - 2026-06-13
### Documentation
+8
View File
@@ -2,6 +2,14 @@
[English](./CHANGELOG.md) | 中文
## 2.5.2 - 2026-06-18
### 修复
- `codex-imagegen`:阻止 Codex 通过复制 `generated_images` 中无关旧图片来代替真正生成的取巧行为。校验改为要求当前线程内 `image_gen` 真正运行的证据——事件流中的 `image_gen` 事件,**或**当前线程 `generated_images` 目录下新生成的 PNG——并在派生 agent 指令中禁止在调用 `image_gen` 之前读取或复用历史图片([#185](https://github.com/JimLiu/baoyu-skills/issues/185)
### 文档
- README:补充 Codex 项目级安装(`.agents/skills`)以及项目级与全局公众号凭证的放置位置
## 2.5.1 - 2026-06-13
### 文档
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jim Liu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28 -1
View File
@@ -19,6 +19,31 @@ Skills shared by Baoyu for improving daily work efficiency with AI Agents (Claud
npx skills add jimliu/baoyu-skills
```
### Codex Project-Level Install
If you only need a subset of skills in one project, you do not need to install the full plugin. Codex scans `.agents/skills` inside a project, so copy or symlink each needed skill as a full directory:
```text
<project>/.agents/skills/baoyu-cover-image/SKILL.md
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
```
For a WeChat Official Account article workflow, the usual minimal set is:
- `baoyu-cover-image`
- `baoyu-article-illustrator`
- `baoyu-post-to-wechat`
You do not need to install `baoyu-markdown-to-html` separately. `baoyu-post-to-wechat` already includes the Markdown to WeChat-ready HTML conversion flow. Install `baoyu-format-markdown` only if you need to first turn raw text or drafts into structured Markdown articles with titles, summaries, headings, bold text, lists, and similar formatting.
Place WeChat API credentials according to the scope you want:
- User-level: `~/.baoyu-skills/.env`
- Project-level: `<project>/.baoyu-skills/.env`
Project-level `.env` files are useful when credentials should apply only to the current project. Do not commit them to Git.
### Publish to ClawHub / OpenClaw
This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill.
@@ -1362,7 +1387,9 @@ This project was inspired by and builds upon the following open source projects:
## License
MIT
Unless otherwise noted, this repository is licensed under the [MIT License](./LICENSE).
Published ClawHub skills follow ClawHub registry rules and are distributed under `MIT-0`. Third-party code and assets retain their original licenses where noted.
## Star History
+28 -1
View File
@@ -29,6 +29,31 @@
npx skills add jimliu/baoyu-skills
```
### Codex 项目级安装
如果只在某个项目中使用部分技能,不需要安装整个插件。Codex 会扫描项目里的 `.agents/skills`,可以只把需要的 skill 按整个目录复制或软链接到当前项目:
```text
<project>/.agents/skills/baoyu-cover-image/SKILL.md
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
```
公众号文章发布的最小组合通常是:
- `baoyu-cover-image`
- `baoyu-article-illustrator`
- `baoyu-post-to-wechat`
不需要单独安装 `baoyu-markdown-to-html``baoyu-post-to-wechat` 已内置 Markdown 到公众号可用 HTML 的转换流程。只有需要先把原始文本或草稿整理成 Markdown 文章结构(标题、摘要、层级标题、加粗、列表等)时,再额外安装 `baoyu-format-markdown`
公众号 API 凭证按作用范围放置:
- 全局:`~/.baoyu-skills/.env`
- 项目:`<project>/.baoyu-skills/.env`
项目级 `.env` 适合只给当前项目使用,注意不要提交到 Git。
### 发布到 ClawHub / OpenClaw
现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。
@@ -1363,7 +1388,9 @@ HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger
## 许可证
MIT
除另有说明外,本仓库采用 [MIT License](./LICENSE) 授权。
发布到 ClawHub 的 skill 根据 ClawHub registry 规则以 `MIT-0` 分发。第三方代码和素材按其注明的原始许可授权。
## Star History
+5
View File
@@ -5,6 +5,7 @@
"packages": {
"": {
"name": "baoyu-skills",
"license": "MIT",
"workspaces": [
"packages/*"
],
@@ -5018,12 +5019,14 @@
},
"packages/baoyu-chrome-cdp": {
"version": "0.1.1",
"license": "MIT",
"engines": {
"bun": ">=1.2.0"
}
},
"packages/baoyu-codex-imagegen": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"codex-imagegen": "src/main.ts"
},
@@ -5033,6 +5036,7 @@
},
"packages/baoyu-fetch": {
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"@mozilla/readability": "^0.6.0",
"chrome-launcher": "^1.2.1",
@@ -5061,6 +5065,7 @@
},
"packages/baoyu-md": {
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.2",
"front-matter": "^4.0.2",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "baoyu-skills",
"private": true,
"license": "MIT",
"type": "module",
"workspaces": [
"packages/*"
+1
View File
@@ -1,6 +1,7 @@
{
"name": "baoyu-chrome-cdp",
"version": "0.1.1",
"license": "MIT",
"type": "module",
"files": [
"dist",
@@ -1,6 +1,7 @@
{
"name": "baoyu-codex-imagegen",
"version": "0.1.0",
"license": "MIT",
"type": "module",
"description": "Generate images via Codex CLI's built-in image_gen tool from non-Codex runtimes (Claude Code, Hermes, …).",
"bin": {
+15 -9
View File
@@ -6,7 +6,7 @@ import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
import { runCodexExec } from "./spawn.ts";
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
import { JsonLogger } from "./logger.ts";
@@ -125,7 +125,7 @@ function buildInstruction(prompt: string, opts: CliOptions): string {
const refHint = opts.refImages.length > 0
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
: "";
return `You have an internal tool called image_gen for image generation. Use it.
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
TASK: Generate an image with the spec below, then save to disk.
@@ -137,12 +137,15 @@ OUTPUT PATH: ${opts.outputPath}
${refHint}
STEPS:
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
2. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
3. Verify with: ls -la ${opts.outputPath}
4. Reply with ONLY this JSON line (no markdown fences, no other text):
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
HARD CONSTRAINTS:
- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
- Do NOT use curl, wget, Python, or any external API.
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
- Use ONLY the image_gen internal tool.`;
@@ -175,13 +178,16 @@ async function attemptGenerate(
throw new GenError("agent_refused", "No thread id in event stream");
}
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
// verify image_gen ran in THIS thread. A PNG in this thread's
// generated_images dir is the real signal (image_gen does not surface as a
// stream item); the stream check is a forward-compatible fallback. The #185
// shortcut (copying an unrelated history image) yields neither.
const ver = await verifyImageGenWasInvoked(run.threadId);
if (!ver.ok) {
// secondary verify: did tool_calls include cp/mv from generated_images to our target
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
}
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
throw new GenError(
"no_image_gen_tool_use",
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
);
}
// verify output
@@ -33,17 +33,26 @@ test("parseEventStream tolerates malformed lines", () => {
expect(r.usage?.input).toBe(1);
});
test("hasImageGenInvocation finds shell calls touching generated_images", () => {
test("hasImageGenInvocation does not infer image_gen from shell copies", () => {
const r = parseEventStream(REAL_PoC_STREAM);
// image_gen itself is not an event; inferred via generated_images cp path
// this test only verifies parser behavior; driver logic lives in validator
const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images"));
expect(hasCp).toBe(true);
expect(hasImageGenInvocation(r.toolCalls)).toBe(false);
});
test("hasImageGenInvocation (proper) returns false when no image_gen tool", () => {
test("hasImageGenInvocation detects real image_gen tool calls only", () => {
expect(hasImageGenInvocation([{ id: "1", tool: "shell", status: "completed" }])).toBe(false);
expect(
hasImageGenInvocation([{ id: "1", tool: "image_gen", status: "completed" }]),
).toBe(true);
});
test("parseEventStream normalizes an image_generation item to image_gen", () => {
const stream = `{"type":"thread.started","thread_id":"t1"}
{"type":"item.started","item":{"id":"g0","type":"image_generation","status":"in_progress"}}
{"type":"item.completed","item":{"id":"g0","type":"image_generation","status":"completed"}}
{"type":"item.completed","item":{"id":"m0","type":"agent_message","text":"done"}}`;
const r = parseEventStream(stream);
expect(r.toolCalls.some((tc) => tc.tool === "image_gen")).toBe(true);
expect(hasImageGenInvocation(r.toolCalls)).toBe(true);
});
@@ -2,11 +2,52 @@ import { test, expect } from "bun:test";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { verifyOutput, verifyImageGenWasInvoked, findCpToTarget } from "./validator.ts";
import { verifyOutput, verifyImageGenWasInvoked, hasImageGenEvidence } from "./validator.ts";
import { parseEventStream } from "./parser.ts";
import { GenError } from "./types.ts";
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// Condensed + sanitized capture of a REAL successful `codex exec --json` run
// (thread 019edc1c…, 16:9 maple-tree image, single attempt). The image_gen
// tool leaves NO stream item — success shows only reasoning / command_execution
// / agent_message, and a `cp` from generated_images/<FULL-thread-id>/ig_*.png.
const REAL_SUCCESS_STREAM = `{"type":"thread.started","thread_id":"019edc1c-e7a3-74f0-a276-13bea71d32d6"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"r0","type":"reasoning"}}
{"type":"item.completed","item":{"id":"r1","type":"reasoning"}}
{"type":"item.completed","item":{"id":"m0","type":"agent_message","text":"Image generation is complete. Locating the newly produced image and copying it to the requested path."}}
{"type":"item.started","item":{"id":"c0","type":"command_execution","command":"ls $CODEX_HOME/generated_images","status":"in_progress"}}
{"type":"item.completed","item":{"id":"c0","type":"command_execution","command":"ls $CODEX_HOME/generated_images","exit_code":1,"status":"failed"}}
{"type":"item.started","item":{"id":"c1","type":"command_execution","command":"cp $CODEX_HOME/generated_images/019edc1c-e7a3-74f0-a276-13bea71d32d6/ig_03eda661.png /Users/x/out/maple.png","status":"in_progress"}}
{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cp $CODEX_HOME/generated_images/019edc1c-e7a3-74f0-a276-13bea71d32d6/ig_03eda661.png /Users/x/out/maple.png","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"{\\"status\\":\\"ok\\",\\"path\\":\\"/Users/x/out/maple.png\\",\\"bytes\\":1317377}"}}
{"type":"turn.completed","usage":{"input_tokens":49489,"cached_input_tokens":34432,"output_tokens":2463,"reasoning_output_tokens":1990}}`;
test("real success stream carries no image_gen item — gating on the stream alone would false-negative (#185)", () => {
const r = parseEventStream(REAL_SUCCESS_STREAM);
expect(r.threadId).toBe("019edc1c-e7a3-74f0-a276-13bea71d32d6");
// success path tools: reasoning / shell / agent_message — never image_gen
expect(r.toolCalls.map((tc) => tc.tool).sort()).toEqual([
"agent_message",
"agent_message",
"reasoning",
"reasoning",
"shell",
"shell",
]);
expect(r.toolCalls.some((tc) => tc.tool === "image_gen")).toBe(false);
// filesystem evidence (PNG in this thread's dir) is what must let it through
expect(hasImageGenEvidence(r.toolCalls, true)).toBe(true);
// with no filesystem evidence and no stream item, it is rejected (the shortcut)
expect(hasImageGenEvidence(r.toolCalls, false)).toBe(false);
});
test("hasImageGenEvidence accepts a real image_gen stream item even without a dir PNG", () => {
expect(hasImageGenEvidence([{ id: "1", tool: "image_gen", status: "completed" }], false)).toBe(true);
expect(hasImageGenEvidence([{ id: "1", tool: "shell", status: "completed" }], false)).toBe(false);
});
test("verifyOutput passes for valid PNG", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
try {
@@ -73,26 +114,3 @@ test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () =>
await rm(tempHome, { recursive: true, force: true });
}
});
test("findCpToTarget detects cp from generated_images", () => {
expect(
findCpToTarget(
[
{
id: "1",
tool: "shell",
status: "completed",
command: "cp ~/.codex/generated_images/thread/ig_x.png /tmp/out.png",
},
],
"/tmp/out.png",
),
).toBe(true);
expect(
findCpToTarget(
[{ id: "1", tool: "shell", status: "completed", command: "ls /tmp" }],
"/tmp/out.png",
),
).toBe(false);
});
+10 -9
View File
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { GenError } from "./types.ts";
import type { ToolCall } from "./types.ts";
import { hasImageGenInvocation } from "./parser.ts";
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -23,15 +24,15 @@ export async function verifyImageGenWasInvoked(threadId: string | null): Promise
}
}
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
return toolCalls.some(
(tc) =>
tc.tool === "shell" &&
typeof tc.command === "string" &&
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
/\b(cp|mv|cat)\b/.test(tc.command) &&
tc.command.includes("generated_images"),
);
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
// not surface as a stream item, so a successful run shows only reasoning/shell/
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
// what proves it. The stream check is kept as a forward-compatible signal in
// case a future Codex version emits the item. The #185 shortcut (copying an
// unrelated history image, which lives under a different thread id) yields
// neither, so it is correctly rejected.
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
return dirHasImage || hasImageGenInvocation(toolCalls);
}
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
+1
View File
@@ -1,6 +1,7 @@
{
"name": "baoyu-fetch",
"version": "0.1.2",
"license": "MIT",
"description": "Read URLs into high-quality Markdown or JSON with Chrome CDP and site adapters.",
"type": "module",
"bin": {
+1
View File
@@ -1,6 +1,7 @@
{
"name": "baoyu-md",
"version": "0.1.1",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -456,19 +456,19 @@ export class GeminiClient extends GemMixin {
const generated_images: GeneratedImage[] = [];
const wants_generated =
get_nested_value(candidate, [12, 7, 0], null) != null ||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text) ||
// Gemini 3 no longer emits the legacy marker in the candidate text; the generated
// image arrives in a later response part. Detect it from the raw response instead.
/\/gg-dl\//.test(txt) ||
/image_generation_content\/\d+/.test(txt);
if (wants_generated) {
const image_part = find_generated_image_part(response_json as unknown[], body_index, candidate_index, 1);
const img_body = image_part?.body ?? null;
if (!img_body) {
throw new ImageGenerationError(
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
);
}
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
// Not every candidate carries a generated image (e.g. multi-candidate responses).
// If this candidate has no image part, skip extraction instead of failing the whole call.
const img_candidate = img_body ? get_nested_value<unknown[]>(img_body, [4, candidate_index], []) : [];
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
if (finished) {
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
@@ -6,7 +6,7 @@ import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
import { runCodexExec } from "./spawn.ts";
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
import { JsonLogger } from "./logger.ts";
@@ -125,7 +125,7 @@ function buildInstruction(prompt: string, opts: CliOptions): string {
const refHint = opts.refImages.length > 0
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
: "";
return `You have an internal tool called image_gen for image generation. Use it.
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
TASK: Generate an image with the spec below, then save to disk.
@@ -137,12 +137,15 @@ OUTPUT PATH: ${opts.outputPath}
${refHint}
STEPS:
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
2. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
3. Verify with: ls -la ${opts.outputPath}
4. Reply with ONLY this JSON line (no markdown fences, no other text):
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
HARD CONSTRAINTS:
- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
- Do NOT use curl, wget, Python, or any external API.
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
- Use ONLY the image_gen internal tool.`;
@@ -175,13 +178,16 @@ async function attemptGenerate(
throw new GenError("agent_refused", "No thread id in event stream");
}
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
// verify image_gen ran in THIS thread. A PNG in this thread's
// generated_images dir is the real signal (image_gen does not surface as a
// stream item); the stream check is a forward-compatible fallback. The #185
// shortcut (copying an unrelated history image) yields neither.
const ver = await verifyImageGenWasInvoked(run.threadId);
if (!ver.ok) {
// secondary verify: did tool_calls include cp/mv from generated_images to our target
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
}
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
throw new GenError(
"no_image_gen_tool_use",
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
);
}
// verify output
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { GenError } from "./types.ts";
import type { ToolCall } from "./types.ts";
import { hasImageGenInvocation } from "./parser.ts";
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -23,15 +24,15 @@ export async function verifyImageGenWasInvoked(threadId: string | null): Promise
}
}
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
return toolCalls.some(
(tc) =>
tc.tool === "shell" &&
typeof tc.command === "string" &&
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
/\b(cp|mv|cat)\b/.test(tc.command) &&
tc.command.includes("generated_images"),
);
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
// not surface as a stream item, so a successful run shows only reasoning/shell/
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
// what proves it. The stream check is kept as a forward-compatible signal in
// case a future Codex version emits the item. The #185 shortcut (copying an
// unrelated history image, which lives under a different thread id) yields
// neither, so it is correctly rejected.
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
return dirHasImage || hasImageGenInvocation(toolCalls);
}
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-wechat
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
version: 1.118.1
version: 1.118.2
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
@@ -98,3 +98,68 @@ test('parseMarkdown resolves encoded spaces and literal percent image paths', as
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
});
test('parseMarkdown renders CJK-adjacent bold and italics (no literal asterisks)', async (t) => {
const root = await makeTempDir('x-md-to-html-cjk-bold-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'分工在变细。**国际大厂卷基础设施,中文项目卷场景落地。**这其实是生态成熟的表现。',
'',
'半角场景 **Top 10 里平均有 8 个** 项目。',
'',
'斜体 *数据来源 GitHub* 收尾。',
'',
'参考 **[docs][d]** 了解更多。',
'',
'[d]: https://example.com',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// Bold directly adjacent to CJK (closing ** followed by CJK) must render.
assert.match(result.html, /<strong>国际大厂卷基础设施,中文项目卷场景落地。<\/strong>/);
assert.match(result.html, /<strong>Top 10 里平均有 8 个<\/strong>/);
// Italics.
assert.match(result.html, /<em>数据来源 GitHub<\/em>/);
// Reference-style links inside emphasis must render as links, not plain text.
assert.match(result.html, /<strong><a href="https:\/\/example\.com" rel="noopener noreferrer nofollow">docs<\/a><\/strong>/);
// No literal emphasis delimiters should leak into the output.
assert.doesNotMatch(result.html, /\*\*/);
assert.doesNotMatch(result.html, /(?<!\*)\*(?!\*)[^*\n]+\*(?!\*)/);
});
test('parseMarkdown does not decode author-written literal HTML entities into tags', async (t) => {
const root = await makeTempDir('x-md-to-html-entities-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'正文中写 &#x3C;b&#x3E;literal&#x3C;/b&#x3E; 想显示字面标签。**加粗**收尾。',
'',
'代码里写 `&#x3C;b&#x3E;` 同样保留。',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// CJK-adjacent bold still renders.
assert.match(result.html, /<strong>加粗<\/strong>/);
// Author-written literal entities must NOT be decoded into real tags.
assert.doesNotMatch(result.html, /<b>literal<\/b>/);
assert.match(result.html, /&lt;b&gt;literal&lt;\/b&gt;/);
});
+16 -2
View File
@@ -136,6 +136,21 @@ function highlightCode(code: string, lang: string): string {
}
}
// Normalize CJK-adjacent emphasis so `marked` renders it correctly.
//
// `marked`'s emphasis tokenizer treats a closing `**`/`*` directly followed by a
// CJK character as not right-flanking, so it leaves the delimiters literal
// (e.g. `**加粗**这` renders as plain text with the asterisks intact). We round-trip
// the markdown through `remark-cjk-friendly`, whose stringify serializes the
// boundary character as an HTML entity (`&#x8FD9;`); the entity is treated as
// punctuation by `marked`'s flanking rules, so emphasis parses as expected.
//
// We deliberately do NOT decode the entities afterward. They are valid HTML
// character references that render correctly when the article HTML is pasted into
// the X editor, and `marked` only emits them for characters outside the emphasis
// span (the boundary char), never inside it. A blanket decode of the rendered
// HTML would risk turning author-written literal entities (e.g. `&#x3C;b&#x3E;`
// meant to display `<b>` as text) into real tags, so we leave them intact.
function preprocessCjkMarkdown(markdown: string): string {
try {
const processor = unified()
@@ -143,8 +158,7 @@ function preprocessCjkMarkdown(markdown: string): string {
.use(remarkCjkFriendly)
.use(remarkStringify);
const result = String(processor.processSync(markdown));
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
return String(processor.processSync(markdown));
} catch {
return markdown;
}
+1 -1
View File
@@ -6,7 +6,7 @@ description: >-
"localize", "本地化", "refined translation", "精细翻译", "proofread translation", "快速翻译", "快翻",
"这篇文章翻译一下", or provides a URL/file with translation intent. Supports three modes
(quick/normal/refined) with custom glossary support.
version: 1.59.0
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-translate
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-wechat-summary
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl), per-user profiles, and per-group fact memory (memory.md) across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
version: 1.117.3
version: 1.117.4
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary