Compare commits

..

5 Commits

Author SHA1 Message Date
Jim Liu 宝玉 f8fb457f36 chore: release v1.117.3 2026-05-20 07:58:44 -05:00
Jim Liu 宝玉 174e472a39 docs(baoyu-wechat-summary): restructure profile fields
Split aliases into group_nicknames (user's own prior names) and
aliases (nicknames from other members). Add tags field for
cross-cutting attributes. Sync SKILL.md version.
2026-05-20 07:58:20 -05:00
Jim Liu 宝玉 8b99fa7af0 fix(baoyu-post-to-wechat): sync SKILL.md version to 1.117.3 2026-05-20 07:58:18 -05:00
Jim Liu 宝玉 6699b802c0 fix(baoyu-diagram): add version field to SKILL.md 2026-05-20 07:58:17 -05:00
Jim Liu 宝玉 edcdc19ac5 feat(ci): add skill release commit validation
Add CI check to ensure commits touching skills/<name>/** use
Conventional Commit subjects. Also validates SKILL.md version
alignment during publish/sync.
2026-05-20 07:58:14 -05:00
17 changed files with 477 additions and 27 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.117.2"
"version": "1.117.3"
},
"plugins": [
{
+5
View File
@@ -11,6 +11,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -18,6 +20,9 @@ jobs:
node-version: 22
cache: npm
- name: Verify skill release commits
run: npm run verify:skill-release-commits
- name: Install dependencies
run: npm ci
+12
View File
@@ -2,6 +2,18 @@
English | [中文](./CHANGELOG.zh.md)
## 1.117.3 - 2026-05-20
### Features
- CI: add skill release commit validation — commits touching `skills/<name>/**` must use Conventional Commit subjects; SKILL.md version validated during publish/sync
### Fixes
- `baoyu-diagram`: add version field to SKILL.md
- `baoyu-post-to-wechat`: sync SKILL.md version
### Documentation
- `baoyu-wechat-summary`: restructure profile fields — split `aliases` into `group_nicknames` (user's own prior names) and `aliases` (nicknames from other members), add `tags` for cross-cutting attributes
## 1.117.2 - 2026-05-17
### Documentation
+12
View File
@@ -2,6 +2,18 @@
[English](./CHANGELOG.md) | 中文
## 1.117.3 - 2026-05-20
### 新功能
- CI:新增 skill 发布提交校验 —— 涉及 `skills/<name>/**` 的提交必须使用 Conventional Commit 格式;发布/同步时校验 SKILL.md 版本一致性
### 修复
- `baoyu-diagram`:为 SKILL.md 添加 version 字段
- `baoyu-post-to-wechat`:同步 SKILL.md 版本
### 文档
- `baoyu-wechat-summary`:重构 profile 字段 —— 将 `aliases` 拆分为 `group_nicknames`(用户历史群名)和 `aliases`(其他成员对用户的称呼),新增 `tags` 字段存储横向属性
## 1.117.2 - 2026-05-17
### 文档
+4
View File
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
## Shared Workspace Packages
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
+2 -1
View File
@@ -7,7 +7,8 @@
],
"scripts": {
"test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
},
"devDependencies": {
"@mozilla/readability": "^0.6.0",
+45
View File
@@ -25,6 +25,37 @@ const MIME_MAP = {
".svg": "image/svg+xml",
};
export async function readSkillMetadataVersion(root) {
const skillFile = await findSkillMarkdown(root);
const source = await fs.readFile(skillFile, "utf8");
const version = readSkillFrontmatterVersion(source);
if (!version) {
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
}
return version;
}
export async function validateSkillMetadataVersion(root, expectedVersion) {
const actualVersion = await readSkillMetadataVersion(root);
if (actualVersion !== expectedVersion) {
throw new Error(
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
);
}
}
export function readSkillFrontmatterVersion(source) {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
if (!match) return null;
for (const line of match[1].split(/\r?\n/)) {
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
if (versionMatch) return versionMatch[1];
}
return null;
}
export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root);
const files = [];
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
return files;
}
async function findSkillMarkdown(root) {
const resolvedRoot = path.resolve(root);
for (const name of ["SKILL.md", "skill.md"]) {
const candidate = path.join(resolvedRoot, name);
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// Try the next supported skill filename.
}
}
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
}
export async function validateSelfContainedRelease(root) {
const resolvedRoot = path.resolve(root);
const files = await listReleaseFiles(root);
+31
View File
@@ -6,6 +6,9 @@ import test from "node:test";
import {
listReleaseFiles,
readSkillFrontmatterVersion,
readSkillMetadataVersion,
validateSkillMetadataVersion,
validateSelfContainedRelease,
} from "./release-files.mjs";
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
);
});
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
});
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
});
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-mismatch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
await assert.rejects(
() => validateSkillMetadataVersion(root, "1.2.4"),
/SKILL\.md version mismatch/,
);
});
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
const root = await makeTempDir("baoyu-release-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
+68
View File
@@ -0,0 +1,68 @@
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
const CONVENTIONAL_SUBJECT_PATTERN =
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
export function parseConventionalCommitSubject(subject) {
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
if (!match?.groups) return null;
return {
type: match.groups.type,
scope: match.groups.scope ?? "",
breaking: Boolean(match.groups.breaking),
description: match.groups.description,
};
}
export function changedSkillsForPaths(paths) {
const skills = new Set();
for (const filePath of paths) {
const normalizedPath = filePath.replaceAll("\\", "/");
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
if (match) skills.add(match[1]);
}
return [...skills].sort((left, right) => left.localeCompare(right));
}
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
const skills = changedSkillsForPaths(paths);
if (skills.length === 0) return [];
const parsed = parseConventionalCommitSubject(subject);
if (parsed) return [];
return [
{
commit,
subject,
skills,
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
},
];
}
export function formatSkillReleaseFailures(failures) {
if (failures.length === 0) return "";
const lines = [
"Skill release commit check failed.",
"",
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
"",
];
for (const failure of failures) {
lines.push(`- ${failure.message}`);
}
return lines.join("\n");
}
function formatCommit(commit) {
return commit ? commit.slice(0, 12) : "<unknown>";
}
function formatSkills(skills) {
return skills.map((skill) => `skills/${skill}/**`).join(", ");
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedSkillsForPaths,
formatSkillReleaseFailures,
parseConventionalCommitSubject,
validateSkillReleaseCommit,
} from "./skill-release-guard.mjs";
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
type: "fix",
scope: "baoyu-post-to-wechat",
breaking: false,
description: "repair editor paste",
});
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
type: "feat",
scope: "",
breaking: true,
description: "change skill metadata format",
});
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
});
test("changedSkillsForPaths returns sorted unique skills", () => {
assert.deepEqual(
changedSkillsForPaths([
"README.md",
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
"skills/baoyu-post-to-wechat/SKILL.md",
"skills/baoyu-url-to-markdown/SKILL.md",
]),
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
);
});
test("validateSkillReleaseCommit ignores non-skill changes", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "Fix test workflow",
paths: [".github/workflows/test.yml"],
}),
[],
);
});
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
const failures = validateSkillReleaseCommit({
commit: "81377416b4a7",
subject: "Fix WeChat browser article publishing",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
});
assert.equal(failures.length, 1);
assert.match(failures[0]!.message, /Conventional Commit/);
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
});
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
}),
[],
);
});
+7 -1
View File
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -21,6 +26,7 @@ async function main() {
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
: "";
await validateSkillMetadataVersion(skillDir, options.version);
await validateSelfContainedRelease(skillDir);
const files = await listReleaseFiles(skillDir);
if (files.length === 0) {
+46 -15
View File
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
readSkillMetadataVersion,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -38,9 +44,11 @@ async function main() {
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
const files = await collectReleaseFiles(skill.folder);
const localVersion = await readSkillMetadataVersion(skill.folder);
const fingerprint = buildFingerprint(files);
return {
...skill,
localVersion,
fileCount: files.length,
fingerprint,
};
@@ -119,12 +127,12 @@ async function main() {
for (const candidate of actionable) {
const version =
candidate.status === "new"
? "1.0.0"
: bumpSemver(candidate.latestVersion, options.bump);
? candidate.localVersion
: resolveUpdateVersion(candidate, options.bump);
console.log(`Publishing ${candidate.slug}@${version}`);
try {
const files = await collectReleaseFiles(candidate.folder);
const files = await collectReleaseFiles(candidate.folder, version);
await publishSkill({
registry,
token: config.token,
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
);
}
async function collectReleaseFiles(root) {
async function collectReleaseFiles(root, expectedVersion = "") {
if (expectedVersion) {
await validateSkillMetadataVersion(root, expectedVersion);
}
await validateSelfContainedRelease(root);
return listReleaseFiles(root);
}
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
function formatCandidate(candidate, bump) {
if (candidate.status === "new") {
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
}
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
candidate.latestVersion,
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
candidate,
bump
)} (${candidate.fileCount} files)`;
}
function bumpSemver(version, bump) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
function resolveUpdateVersion(candidate, bump) {
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
return candidate.localVersion;
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
return bumpSemver(candidate.latestVersion, bump);
}
function compareSemver(left, right) {
const leftParts = parseSemver(left);
const rightParts = parseSemver(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
function bumpSemver(version, bump) {
const [major, minor, patch] = parseSemver(version);
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function parseSemver(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function sanitizeSlug(value) {
return value
.trim()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import {
formatSkillReleaseFailures,
validateSkillReleaseCommit,
} from "./lib/skill-release-guard.mjs";
const execFileAsync = promisify(execFile);
const ZERO_SHA = /^0{40}$/;
async function main() {
const options = parseArgs(process.argv.slice(2));
const range = await resolveRange(options);
const commits = await listCommits(range);
const failures = [];
for (const commit of commits) {
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
}
if (failures.length > 0) {
console.error(formatSkillReleaseFailures(failures));
process.exit(1);
}
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
}
function parseArgs(argv) {
const options = {
base: "",
head: "HEAD",
range: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
options.base = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--head") {
options.head = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--range") {
options.range = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printUsage() {
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
Checks non-merge commits in the selected range. Any commit that touches
skills/<name>/** must use a Conventional Commit subject, for example:
fix(baoyu-post-to-wechat): handle WeChat editor focus
Without explicit arguments, GitHub Actions event metadata is used when
available. Otherwise the fallback range is HEAD^..HEAD.`);
}
async function resolveRange(options) {
if (options.range) {
return { args: [options.range], label: options.range };
}
if (options.base) {
return {
args: [`${options.base}..${options.head || "HEAD"}`],
label: `${options.base}..${options.head || "HEAD"}`,
};
}
const githubRange = await resolveGitHubRange();
if (githubRange) return githubRange;
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
}
async function resolveGitHubRange() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath) return null;
let event = null;
try {
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
} catch {
return null;
}
if (event?.pull_request?.base?.sha) {
const base = event.pull_request.base.sha;
const head = process.env.GITHUB_SHA || "HEAD";
return { args: [`${base}..${head}`], label: `${base}..${head}` };
}
if (event?.before && !ZERO_SHA.test(event.before)) {
const head = event.after || process.env.GITHUB_SHA || "HEAD";
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
}
return null;
}
async function listCommits(range) {
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function readCommitSubject(commit) {
return git(["log", "-1", "--format=%s", commit]);
}
async function readCommitPaths(commit) {
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function git(args) {
const { stdout } = await execFileAsync("git", args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trimEnd();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+1
View File
@@ -1,6 +1,7 @@
---
name: baoyu-diagram
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
version: 1.117.3
---
# Diagram Generator
+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.56.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
+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) and per-user profiles 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: 0.1.0
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
@@ -32,7 +32,9 @@ YAML frontmatter at the top of every profile file:
---
name: "<current display name>"
wxid: "<wxid>"
aliases: ["<old nickname>", "<even older nickname>"]
group_nicknames: ["<历史群昵称 1>", "<历史群昵称 2>"]
aliases: ["<群友给的称呼 1>", "<群友给的称呼 2>"]
tags: ["<标签 1>", "<标签 2>"]
first_seen: "YYYY-MM-DD"
last_seen: "YYYY-MM-DD"
total_messages: N
@@ -45,12 +47,16 @@ Field rules:
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
- `wxid`: stable; never changes once written.
- `aliases`: append-only; every prior display name we've seen. Don't include the current `name` in this list.
- `group_nicknames`: append-only history of the user's own prior display names in the group. Push the prior `name` here when `name` changes. Dedupe, preserve chronological order (oldest → newest). Do not include the current `name`.
- `aliases`: nicknames **other members** call this user (e.g., `蛙总`, `老王`, `X 哥`). Dedupe-append when observed in this batch. Do not include the current `name`, and do not duplicate `group_nicknames` entries — those record the user's own past handles, not how the group addresses them.
- `tags`: free-form labels for the user, **independent** of the body's 角色标签 / 人设标签 section. Use for cross-cutting attributes that don't fit the role/personality framing (region, profession, community, recurring long-form interests, etc.). Agent may append or refine when observing stable patterns. No hard cap.
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
- `total_messages`: cumulative count across all digests this profile has been updated from.
- `digest_appearances`: how many digest files this user has 3+ messages in.
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
**Backwards compatibility**: earlier versions of this skill used `aliases` for what is now `group_nicknames`. When reading an existing profile that lacks `group_nicknames` or `tags`, treat missing fields as `[]` and add them on the next write. **Do not auto-migrate** non-empty legacy `aliases` values — the agent can't reliably tell historical display names apart from community-given nicknames. Leave the values in `aliases`; the user can move historical display names into `group_nicknames` manually if desired.
### 1.3 Free-form body — normal profile
Section headers are plain text on their own line. Order is fixed.
@@ -138,8 +144,16 @@ Rules differ per section. Append-only sections must never lose history; mergeabl
### 2.3 Frontmatter on every update
- Update `name` if current display name differs from the recorded one. Push the old name onto `aliases` if not already there.
- If `name` changed, also rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- If the current display name differs from the recorded `name`:
- Push the old `name` onto `group_nicknames` if not already there (dedupe, preserve chronological order).
- Update `name` to the current display name.
- Rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- Scan this batch for nicknames **other members** use to address this user, and dedupe-append into `aliases`. Signals:
- `@mention` resolving to this `wxid`.
- Direct salutations targeting this user with a name different from `name` (e.g., `蛙总你怎么看`, `老王说得对`).
- Quoted references in the digest body that name this user as someone other than their current `name`.
- Only add when attribution is unambiguous; skip uncertain matches.
- If this batch reveals a stable cross-cutting attribute that doesn't fit the role/personality framing of 角色标签 / 人设标签 (region, profession, community, durable interest, etc.), append or refine `tags`. `tags` is independent of the body's tag sections — don't mirror them.
- Update `last_seen` to the current digest's end date.
- Increment `total_messages` by this batch's message count for this user.
- Increment `digest_appearances` by 1.
@@ -154,7 +168,7 @@ Run after the digest file(s) are written. Iterate over every user with 3+ messag
1. **Look up the profile.**
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
- If found: open it.
- If not found: create a new file using the frontmatter template. `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`.
- If not found: create a new file using the frontmatter template. `group_nicknames = []`, `aliases = []`, `tags = []`, `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`. Then run §2.3 to seed observed aliases/tags from this batch.
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
@@ -195,7 +209,7 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
5. **Write profile files.**
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
- Use the most recent nickname as the filename suffix. Push older nicknames into `aliases`.
- Use the most recent nickname as the filename suffix. Push older display names into `group_nicknames` (see step 6 for the field-by-field rules).
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
- No cap on the size of append-only sections during backfill — let history flow in.
@@ -204,6 +218,9 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
- `last_seen` = latest digest date the user appeared in.
- `total_messages` = sum of per-digest counts.
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
- `group_nicknames` = best-effort. If the same `wxid` appears under multiple distinct display names across historical digests (e.g., via the leaderboard line "X — N 条" where X varied), fill the older ones in chronological order (newest stays in `name`). If chronological order is unclear, dedupe and let later runs correct.
- `aliases` = best-effort. Scan historical digest bodies for forms where another member calls this user by a name different from their current `name` (@mentions, direct salutations). Skip uncertain matches; leave `[]` if nothing reliable surfaces.
- `tags` = `[]`. Backfill does not seed `tags`; let normal runs accumulate them.
7. **Report.** After both passes complete, print a short summary:
- `Backfilled {N} normal profiles from {M} digests.`
@@ -264,7 +281,8 @@ When loading profile context for a fresh digest:
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
4. Compile a condensed working-memory block:
- The user's current `name` and `aliases` (so you can recognize them under different names).
- The user's current `name`, `group_nicknames`, and `aliases` (so you can recognize them under prior display names or community-given nicknames).
- `tags` (cross-cutting attributes — region, profession, community — useful for callouts in 群友画像).
- 角色标签 / 人设标签 (so you can carry forward or contrast).
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).