autocrew 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HAMLETDEER.md +562 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/README_CN.md +190 -0
- package/adapters/openclaw/index.ts +68 -0
- package/bin/autocrew.mjs +23 -0
- package/bin/autocrew.ts +13 -0
- package/openclaw.plugin.json +36 -0
- package/package.json +74 -0
- package/skills/_writing-style/SKILL.md +68 -0
- package/skills/audience-profiler/SKILL.md +241 -0
- package/skills/content-attribution/SKILL.md +128 -0
- package/skills/content-review/SKILL.md +257 -0
- package/skills/cover-generator/SKILL.md +93 -0
- package/skills/humanizer-zh/SKILL.md +75 -0
- package/skills/intel-digest/SKILL.md +57 -0
- package/skills/intel-pull/SKILL.md +74 -0
- package/skills/manage-pipeline/SKILL.md +63 -0
- package/skills/memory-distill/SKILL.md +89 -0
- package/skills/onboarding/SKILL.md +117 -0
- package/skills/pipeline-status/SKILL.md +51 -0
- package/skills/platform-rewrite/SKILL.md +125 -0
- package/skills/pre-publish/SKILL.md +142 -0
- package/skills/publish-content/SKILL.md +500 -0
- package/skills/remix-content/SKILL.md +77 -0
- package/skills/research/SKILL.md +127 -0
- package/skills/setup/SKILL.md +353 -0
- package/skills/spawn-batch-writer/SKILL.md +66 -0
- package/skills/spawn-planner/SKILL.md +72 -0
- package/skills/spawn-writer/SKILL.md +60 -0
- package/skills/teardown/SKILL.md +144 -0
- package/skills/title-craft/SKILL.md +234 -0
- package/skills/topic-ideas/SKILL.md +105 -0
- package/skills/video-timeline/SKILL.md +117 -0
- package/skills/write-script/SKILL.md +232 -0
- package/skills/xhs-cover-review/SKILL.md +48 -0
- package/src/adapters/browser/browser-cdp.ts +260 -0
- package/src/adapters/browser/browser-relay.ts +236 -0
- package/src/adapters/browser/gateway-client.ts +148 -0
- package/src/adapters/browser/types.ts +36 -0
- package/src/adapters/image/gemini.ts +219 -0
- package/src/adapters/research/tikhub.ts +19 -0
- package/src/cli/banner.ts +18 -0
- package/src/cli/bootstrap.ts +33 -0
- package/src/cli/commands/adapt.ts +28 -0
- package/src/cli/commands/advance.ts +28 -0
- package/src/cli/commands/assets.ts +24 -0
- package/src/cli/commands/audit.ts +18 -0
- package/src/cli/commands/contents.ts +18 -0
- package/src/cli/commands/cover.ts +58 -0
- package/src/cli/commands/events.ts +17 -0
- package/src/cli/commands/humanize.ts +27 -0
- package/src/cli/commands/index.ts +80 -0
- package/src/cli/commands/init.ts +28 -0
- package/src/cli/commands/intel.ts +55 -0
- package/src/cli/commands/learn.ts +34 -0
- package/src/cli/commands/memory.ts +18 -0
- package/src/cli/commands/migrate.ts +24 -0
- package/src/cli/commands/open.ts +21 -0
- package/src/cli/commands/pipelines.ts +18 -0
- package/src/cli/commands/pre-publish.ts +27 -0
- package/src/cli/commands/profile.ts +31 -0
- package/src/cli/commands/research.ts +36 -0
- package/src/cli/commands/restore.ts +28 -0
- package/src/cli/commands/review.ts +61 -0
- package/src/cli/commands/start.ts +28 -0
- package/src/cli/commands/status.ts +14 -0
- package/src/cli/commands/templates.ts +15 -0
- package/src/cli/commands/topics.ts +18 -0
- package/src/cli/commands/trash.ts +28 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/commands/versions.ts +24 -0
- package/src/cli/index.ts +40 -0
- package/src/data/sensitive-words-builtin.json +114 -0
- package/src/data/source-presets.yaml +54 -0
- package/src/e2e.test.ts +596 -0
- package/src/modules/auth/cookie-manager.ts +113 -0
- package/src/modules/cards/template-engine.ts +74 -0
- package/src/modules/cards/templates/comparison-table.ts +71 -0
- package/src/modules/cards/templates/data-chart.ts +76 -0
- package/src/modules/cards/templates/flow-chart.ts +49 -0
- package/src/modules/cards/templates/key-points.ts +59 -0
- package/src/modules/cover/prompt-builder.test.ts +157 -0
- package/src/modules/cover/prompt-builder.ts +212 -0
- package/src/modules/cover/ratio-adapter.test.ts +122 -0
- package/src/modules/cover/ratio-adapter.ts +104 -0
- package/src/modules/filter/sensitive-words.test.ts +72 -0
- package/src/modules/filter/sensitive-words.ts +212 -0
- package/src/modules/humanizer/zh.test.ts +75 -0
- package/src/modules/humanizer/zh.ts +175 -0
- package/src/modules/intel/collector.ts +19 -0
- package/src/modules/intel/collectors/competitor.test.ts +71 -0
- package/src/modules/intel/collectors/competitor.ts +65 -0
- package/src/modules/intel/collectors/rss.test.ts +56 -0
- package/src/modules/intel/collectors/rss.ts +70 -0
- package/src/modules/intel/collectors/trends.test.ts +80 -0
- package/src/modules/intel/collectors/trends.ts +107 -0
- package/src/modules/intel/collectors/web-search.test.ts +85 -0
- package/src/modules/intel/collectors/web-search.ts +81 -0
- package/src/modules/intel/integration.test.ts +203 -0
- package/src/modules/intel/intel-engine.test.ts +103 -0
- package/src/modules/intel/intel-engine.ts +96 -0
- package/src/modules/intel/source-config.test.ts +113 -0
- package/src/modules/intel/source-config.ts +131 -0
- package/src/modules/learnings/diff-tracker.test.ts +144 -0
- package/src/modules/learnings/diff-tracker.ts +189 -0
- package/src/modules/learnings/rule-distiller.ts +141 -0
- package/src/modules/memory/distill.ts +208 -0
- package/src/modules/migrate/legacy-migrate.test.ts +169 -0
- package/src/modules/migrate/legacy-migrate.ts +229 -0
- package/src/modules/pro/api-client.ts +192 -0
- package/src/modules/pro/gate.test.ts +110 -0
- package/src/modules/pro/gate.ts +104 -0
- package/src/modules/profile/creator-profile.test.ts +178 -0
- package/src/modules/profile/creator-profile.ts +248 -0
- package/src/modules/publish/douyin-api.ts +34 -0
- package/src/modules/publish/wechat-mp.ts +320 -0
- package/src/modules/publish/xiaohongshu-api.ts +127 -0
- package/src/modules/research/free-engine.ts +360 -0
- package/src/modules/timeline/markup-generator.ts +63 -0
- package/src/modules/timeline/parser.ts +275 -0
- package/src/modules/workflow/templates.ts +124 -0
- package/src/modules/writing/platform-rewrite.ts +190 -0
- package/src/modules/writing/title-hashtag.ts +385 -0
- package/src/runtime/context.test.ts +97 -0
- package/src/runtime/context.ts +129 -0
- package/src/runtime/events.test.ts +83 -0
- package/src/runtime/events.ts +104 -0
- package/src/runtime/hooks.ts +174 -0
- package/src/runtime/tool-runner.test.ts +204 -0
- package/src/runtime/tool-runner.ts +282 -0
- package/src/runtime/workflow-engine.test.ts +455 -0
- package/src/runtime/workflow-engine.ts +391 -0
- package/src/server/index.ts +409 -0
- package/src/server/start.ts +39 -0
- package/src/storage/local-store.test.ts +304 -0
- package/src/storage/local-store.ts +704 -0
- package/src/storage/pipeline-store.test.ts +363 -0
- package/src/storage/pipeline-store.ts +698 -0
- package/src/tools/asset.ts +96 -0
- package/src/tools/content-save.ts +276 -0
- package/src/tools/cover-review.ts +221 -0
- package/src/tools/humanize.ts +54 -0
- package/src/tools/init.ts +133 -0
- package/src/tools/intel.ts +92 -0
- package/src/tools/memory.ts +76 -0
- package/src/tools/pipeline-ops.ts +109 -0
- package/src/tools/pipeline.ts +168 -0
- package/src/tools/pre-publish.ts +232 -0
- package/src/tools/publish.ts +183 -0
- package/src/tools/registry.ts +198 -0
- package/src/tools/research.ts +304 -0
- package/src/tools/review.ts +305 -0
- package/src/tools/rewrite.ts +165 -0
- package/src/tools/status.ts +30 -0
- package/src/tools/timeline.ts +234 -0
- package/src/tools/topic-create.ts +50 -0
- package/src/types/providers.ts +69 -0
- package/src/types/timeline.test.ts +147 -0
- package/src/types/timeline.ts +83 -0
- package/src/utils/retry.test.ts +97 -0
- package/src/utils/retry.ts +85 -0
- package/templates/AGENTS.md +99 -0
- package/templates/SOUL.md +31 -0
- package/templates/TOOLS.md +76 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { addAsset, listAssets, removeAsset, listVersions, getVersion, revertToVersion } from "../storage/local-store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* autocrew_asset — manage media files (covers, B-Roll, images, videos, subtitles)
|
|
6
|
+
* and version history for content projects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const assetSchema = Type.Object({
|
|
10
|
+
action: Type.Unsafe<"add" | "list" | "remove" | "versions" | "get_version" | "revert">({
|
|
11
|
+
type: "string",
|
|
12
|
+
enum: ["add", "list", "remove", "versions", "get_version", "revert"],
|
|
13
|
+
description:
|
|
14
|
+
"Action: 'add' asset to content, 'list' assets, 'remove' asset, 'versions' list version history, 'get_version' read a specific version, 'revert' to a previous version.",
|
|
15
|
+
}),
|
|
16
|
+
content_id: Type.String({ description: "Content project id (e.g. content-xxx)" }),
|
|
17
|
+
filename: Type.Optional(Type.String({ description: "Asset filename (for add/remove)" })),
|
|
18
|
+
asset_type: Type.Optional(
|
|
19
|
+
Type.Unsafe<"cover" | "broll" | "image" | "video" | "audio" | "subtitle" | "other">({
|
|
20
|
+
type: "string",
|
|
21
|
+
enum: ["cover", "broll", "image", "video", "audio", "subtitle", "other"],
|
|
22
|
+
description: "Asset type (for add)",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
description: Type.Optional(Type.String({ description: "Asset description (for add)" })),
|
|
26
|
+
source_path: Type.Optional(
|
|
27
|
+
Type.String({ description: "Absolute path to source file to copy into the project (for add)" }),
|
|
28
|
+
),
|
|
29
|
+
version: Type.Optional(Type.Number({ description: "Version number (for get_version/revert)" })),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function executeAsset(params: Record<string, unknown>) {
|
|
33
|
+
const action = params.action as string;
|
|
34
|
+
const contentId = params.content_id as string;
|
|
35
|
+
const dataDir = (params._dataDir as string) || undefined;
|
|
36
|
+
|
|
37
|
+
if (!contentId) {
|
|
38
|
+
return { ok: false, error: "content_id is required" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Asset operations ---
|
|
42
|
+
|
|
43
|
+
if (action === "add") {
|
|
44
|
+
const filename = params.filename as string;
|
|
45
|
+
const assetType = (params.asset_type as string) || "other";
|
|
46
|
+
if (!filename) return { ok: false, error: "filename is required for add" };
|
|
47
|
+
const result = await addAsset(
|
|
48
|
+
contentId,
|
|
49
|
+
{
|
|
50
|
+
filename,
|
|
51
|
+
type: assetType as any,
|
|
52
|
+
description: (params.description as string) || undefined,
|
|
53
|
+
sourcePath: (params.source_path as string) || undefined,
|
|
54
|
+
},
|
|
55
|
+
dataDir,
|
|
56
|
+
);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (action === "list") {
|
|
61
|
+
const assets = await listAssets(contentId, dataDir);
|
|
62
|
+
return { ok: true, content_id: contentId, assets };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (action === "remove") {
|
|
66
|
+
const filename = params.filename as string;
|
|
67
|
+
if (!filename) return { ok: false, error: "filename is required for remove" };
|
|
68
|
+
const removed = await removeAsset(contentId, filename, dataDir);
|
|
69
|
+
return { ok: removed, message: removed ? `Removed ${filename}` : "Asset not found" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Version operations ---
|
|
73
|
+
|
|
74
|
+
if (action === "versions") {
|
|
75
|
+
const versions = await listVersions(contentId, dataDir);
|
|
76
|
+
return { ok: true, content_id: contentId, versions };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (action === "get_version") {
|
|
80
|
+
const ver = params.version as number;
|
|
81
|
+
if (!ver) return { ok: false, error: "version number is required" };
|
|
82
|
+
const body = await getVersion(contentId, ver, dataDir);
|
|
83
|
+
if (!body) return { ok: false, error: `Version ${ver} not found` };
|
|
84
|
+
return { ok: true, content_id: contentId, version: ver, body };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (action === "revert") {
|
|
88
|
+
const ver = params.version as number;
|
|
89
|
+
if (!ver) return { ok: false, error: "version number is required" };
|
|
90
|
+
const content = await revertToVersion(contentId, ver, dataDir);
|
|
91
|
+
if (!content) return { ok: false, error: `Failed to revert to version ${ver}` };
|
|
92
|
+
return { ok: true, content };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
96
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import {
|
|
6
|
+
saveContent,
|
|
7
|
+
listContents,
|
|
8
|
+
getContent,
|
|
9
|
+
updateContent,
|
|
10
|
+
transitionStatus,
|
|
11
|
+
createPlatformVariant,
|
|
12
|
+
listSiblings,
|
|
13
|
+
getAllowedTransitions,
|
|
14
|
+
normalizeLegacyStatus,
|
|
15
|
+
} from "../storage/local-store.js";
|
|
16
|
+
import type { ContentStatus } from "../storage/local-store.js";
|
|
17
|
+
import {
|
|
18
|
+
slugify,
|
|
19
|
+
stagePath,
|
|
20
|
+
initPipeline,
|
|
21
|
+
addDraftVersion,
|
|
22
|
+
type ProjectMeta,
|
|
23
|
+
} from "../storage/pipeline-store.js";
|
|
24
|
+
import { executeHumanize } from "./humanize.js";
|
|
25
|
+
|
|
26
|
+
const ALL_STATUSES = [
|
|
27
|
+
"topic_saved", "drafting", "draft_ready", "reviewing", "revision",
|
|
28
|
+
"approved", "cover_pending", "publish_ready", "publishing", "published", "archived",
|
|
29
|
+
// Legacy compat
|
|
30
|
+
"draft", "review",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export const contentSaveSchema = Type.Object({
|
|
34
|
+
action: Type.Unsafe<"save" | "list" | "get" | "update" | "transition" | "create_variant" | "siblings" | "allowed_transitions">({
|
|
35
|
+
type: "string",
|
|
36
|
+
enum: ["save", "list", "get", "update", "transition", "create_variant", "siblings", "allowed_transitions"],
|
|
37
|
+
description:
|
|
38
|
+
"Action: 'save' new content, 'list' all, 'get' by id, 'update' existing, " +
|
|
39
|
+
"'transition' change status via state machine, 'create_variant' create platform variant from topic, " +
|
|
40
|
+
"'siblings' list sibling content, 'allowed_transitions' show valid next statuses.",
|
|
41
|
+
}),
|
|
42
|
+
id: Type.Optional(Type.String({ description: "Content id (for get/update/transition/siblings/allowed_transitions)" })),
|
|
43
|
+
title: Type.Optional(Type.String({ description: "Content title" })),
|
|
44
|
+
body: Type.Optional(Type.String({ description: "Content body (markdown)" })),
|
|
45
|
+
platform: Type.Optional(Type.String({ description: "Target platform: xhs, douyin, wechat_video, wechat_mp, bilibili" })),
|
|
46
|
+
topicId: Type.Optional(Type.String({ description: "Related topic id (for save/create_variant)" })),
|
|
47
|
+
status: Type.Optional(Type.Unsafe<string>({
|
|
48
|
+
type: "string",
|
|
49
|
+
enum: ALL_STATUSES as unknown as string[],
|
|
50
|
+
description: "Content status (for save/update). Use 'transition' action for validated state changes.",
|
|
51
|
+
})),
|
|
52
|
+
target_status: Type.Optional(Type.Unsafe<string>({
|
|
53
|
+
type: "string",
|
|
54
|
+
enum: ALL_STATUSES as unknown as string[],
|
|
55
|
+
description: "Target status for 'transition' action.",
|
|
56
|
+
})),
|
|
57
|
+
tags: Type.Optional(Type.Array(Type.String())),
|
|
58
|
+
hashtags: Type.Optional(Type.Array(Type.String(), { description: "Platform-specific hashtags" })),
|
|
59
|
+
siblings: Type.Optional(Type.Array(Type.String(), { description: "Sibling content IDs" })),
|
|
60
|
+
publish_url: Type.Optional(Type.String({ description: "Published URL on target platform" })),
|
|
61
|
+
performance_data: Type.Optional(Type.Record(Type.String(), Type.Number(), { description: "Performance metrics: views, likes, comments, shares, etc." })),
|
|
62
|
+
force: Type.Optional(Type.Boolean({ description: "Force transition even if not in allowed transitions" })),
|
|
63
|
+
diff_note: Type.Optional(Type.String({ description: "Note for revision diff tracking" })),
|
|
64
|
+
hypothesis: Type.Optional(Type.String({ description: "Traffic hypothesis: what this content tests and expected outcome" })),
|
|
65
|
+
experiment_type: Type.Optional(Type.Unsafe<string>({
|
|
66
|
+
type: "string",
|
|
67
|
+
enum: ["title_test", "hook_test", "format_test", "angle_test"],
|
|
68
|
+
description: "Type of experiment this content represents",
|
|
69
|
+
})),
|
|
70
|
+
control_ref: Type.Optional(Type.String({ description: "Content ID this is being A/B tested against" })),
|
|
71
|
+
content_pillar: Type.Optional(Type.String({ description: "Which content pillar this belongs to" })),
|
|
72
|
+
comment_triggers: Type.Optional(Type.Array(
|
|
73
|
+
Type.Object({
|
|
74
|
+
type: Type.Unsafe<string>({ type: "string", enum: ["controversy", "unanswered_question", "quote_hook"] }),
|
|
75
|
+
position: Type.String(),
|
|
76
|
+
}),
|
|
77
|
+
{ description: "Comment engineering trigger points" },
|
|
78
|
+
)),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export async function executeContentSave(params: Record<string, unknown>) {
|
|
82
|
+
const action = (params.action as string) || "save";
|
|
83
|
+
const dataDir = (params._dataDir as string) || undefined;
|
|
84
|
+
|
|
85
|
+
if (action === "list") {
|
|
86
|
+
const contents = await listContents(dataDir);
|
|
87
|
+
return { ok: true, contents };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (action === "get") {
|
|
91
|
+
const id = params.id as string;
|
|
92
|
+
if (!id) return { ok: false, error: "id is required for get" };
|
|
93
|
+
const content = await getContent(id, dataDir);
|
|
94
|
+
if (!content) return { ok: false, error: `Content ${id} not found` };
|
|
95
|
+
return { ok: true, content };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (action === "update") {
|
|
99
|
+
const id = params.id as string;
|
|
100
|
+
if (!id) return { ok: false, error: "id is required for update" };
|
|
101
|
+
const updated = await updateContent(id, {
|
|
102
|
+
title: params.title as string | undefined,
|
|
103
|
+
body: params.body as string | undefined,
|
|
104
|
+
platform: params.platform as string | undefined,
|
|
105
|
+
status: params.status ? normalizeLegacyStatus(params.status as string) : undefined,
|
|
106
|
+
tags: params.tags as string[] | undefined,
|
|
107
|
+
hashtags: params.hashtags as string[] | undefined,
|
|
108
|
+
siblings: params.siblings as string[] | undefined,
|
|
109
|
+
publishUrl: params.publish_url as string | undefined,
|
|
110
|
+
performanceData: params.performance_data as Record<string, number> | undefined,
|
|
111
|
+
}, dataDir);
|
|
112
|
+
if (!updated) return { ok: false, error: `Content ${id} not found` };
|
|
113
|
+
|
|
114
|
+
// Also version in pipeline storage if body changed
|
|
115
|
+
if (params.body && updated.title) {
|
|
116
|
+
try {
|
|
117
|
+
const projectName = slugify(updated.title);
|
|
118
|
+
await addDraftVersion(
|
|
119
|
+
projectName,
|
|
120
|
+
params.body as string,
|
|
121
|
+
(params.diff_note as string) || "Edit via update",
|
|
122
|
+
dataDir ? path.join(dataDir, "data") : undefined,
|
|
123
|
+
);
|
|
124
|
+
} catch {
|
|
125
|
+
// Pipeline project may not exist for legacy content — that's OK
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { ok: true, content: updated };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (action === "transition") {
|
|
133
|
+
const id = params.id as string;
|
|
134
|
+
const targetStatus = params.target_status as string;
|
|
135
|
+
if (!id) return { ok: false, error: "id is required for transition" };
|
|
136
|
+
if (!targetStatus) return { ok: false, error: "target_status is required for transition" };
|
|
137
|
+
const result = await transitionStatus(
|
|
138
|
+
id,
|
|
139
|
+
normalizeLegacyStatus(targetStatus),
|
|
140
|
+
{ force: params.force as boolean, diffNote: params.diff_note as string },
|
|
141
|
+
dataDir,
|
|
142
|
+
);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (action === "create_variant") {
|
|
147
|
+
const topicId = params.topicId as string;
|
|
148
|
+
const platform = params.platform as string;
|
|
149
|
+
if (!topicId) return { ok: false, error: "topicId is required for create_variant" };
|
|
150
|
+
if (!platform) return { ok: false, error: "platform is required for create_variant" };
|
|
151
|
+
const result = await createPlatformVariant(
|
|
152
|
+
topicId,
|
|
153
|
+
platform,
|
|
154
|
+
{ title: params.title as string, body: params.body as string },
|
|
155
|
+
dataDir,
|
|
156
|
+
);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (action === "siblings") {
|
|
161
|
+
const id = params.id as string;
|
|
162
|
+
if (!id) return { ok: false, error: "id is required for siblings" };
|
|
163
|
+
const sibs = await listSiblings(id, dataDir);
|
|
164
|
+
return { ok: true, siblings: sibs };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (action === "allowed_transitions") {
|
|
168
|
+
const id = params.id as string;
|
|
169
|
+
if (!id) return { ok: false, error: "id is required for allowed_transitions" };
|
|
170
|
+
const content = await getContent(id, dataDir);
|
|
171
|
+
if (!content) return { ok: false, error: `Content ${id} not found` };
|
|
172
|
+
const currentStatus = normalizeLegacyStatus(content.status);
|
|
173
|
+
const allowed = getAllowedTransitions(currentStatus);
|
|
174
|
+
return { ok: true, currentStatus, allowedTransitions: allowed };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// save
|
|
178
|
+
const title = params.title as string;
|
|
179
|
+
const body = params.body as string;
|
|
180
|
+
if (!title || !body) {
|
|
181
|
+
return { ok: false, error: "title and body are required for save" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rawStatus = (params.status as string) || "draft_ready";
|
|
185
|
+
const platform = (params.platform as string) || undefined;
|
|
186
|
+
|
|
187
|
+
// 1. Save to old contents/ system (backward compat)
|
|
188
|
+
const content = await saveContent({
|
|
189
|
+
title,
|
|
190
|
+
body,
|
|
191
|
+
platform,
|
|
192
|
+
topicId: (params.topicId as string) || undefined,
|
|
193
|
+
status: normalizeLegacyStatus(rawStatus),
|
|
194
|
+
tags: (params.tags as string[]) || [],
|
|
195
|
+
hashtags: (params.hashtags as string[]) || [],
|
|
196
|
+
}, dataDir);
|
|
197
|
+
|
|
198
|
+
// 2. Also create project in new pipeline/drafting/ structure
|
|
199
|
+
const effectiveDataDir = dataDir || path.join(process.env.HOME ?? "~", ".autocrew");
|
|
200
|
+
await initPipeline(effectiveDataDir);
|
|
201
|
+
|
|
202
|
+
const projectName = slugify(title);
|
|
203
|
+
const draftingDir = stagePath("drafting", effectiveDataDir);
|
|
204
|
+
const projectDir = path.join(draftingDir, projectName);
|
|
205
|
+
await fs.mkdir(path.join(projectDir, "references"), { recursive: true });
|
|
206
|
+
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const meta: ProjectMeta = {
|
|
209
|
+
title,
|
|
210
|
+
domain: "",
|
|
211
|
+
format: platform || "article",
|
|
212
|
+
createdAt: now,
|
|
213
|
+
sourceTopic: "",
|
|
214
|
+
intelRefs: [],
|
|
215
|
+
versions: [{ file: "draft-v1.md", createdAt: now, note: "initial draft" }],
|
|
216
|
+
current: "draft-v1.md",
|
|
217
|
+
history: [{ stage: "drafting", entered: now }],
|
|
218
|
+
platforms: platform ? [{ format: platform, status: "drafting" }] : [],
|
|
219
|
+
hypothesis: (params.hypothesis as string) || undefined,
|
|
220
|
+
experimentType: (params.experiment_type as ProjectMeta["experimentType"]) || undefined,
|
|
221
|
+
controlRef: (params.control_ref as string) || undefined,
|
|
222
|
+
contentPillar: (params.content_pillar as string) || undefined,
|
|
223
|
+
commentTriggers: (params.comment_triggers as ProjectMeta["commentTriggers"]) || undefined,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await fs.writeFile(
|
|
227
|
+
path.join(projectDir, "meta.yaml"),
|
|
228
|
+
yaml.dump(meta, { lineWidth: -1 }),
|
|
229
|
+
"utf-8",
|
|
230
|
+
);
|
|
231
|
+
await fs.writeFile(
|
|
232
|
+
path.join(projectDir, "draft-v1.md"),
|
|
233
|
+
body,
|
|
234
|
+
"utf-8",
|
|
235
|
+
);
|
|
236
|
+
await fs.writeFile(
|
|
237
|
+
path.join(projectDir, "draft.md"),
|
|
238
|
+
`# ${title}\n\n${body}\n`,
|
|
239
|
+
"utf-8",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Auto-humanize (tool-level enforcement — LLM cannot skip this)
|
|
243
|
+
let humanizeResult: Record<string, unknown> | null = null;
|
|
244
|
+
try {
|
|
245
|
+
humanizeResult = await executeHumanize({
|
|
246
|
+
action: "humanize_zh",
|
|
247
|
+
content_id: content.id,
|
|
248
|
+
save_back: true,
|
|
249
|
+
_dataDir: dataDir,
|
|
250
|
+
}) as Record<string, unknown>;
|
|
251
|
+
} catch {
|
|
252
|
+
// Humanize failure should not block save
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
content,
|
|
258
|
+
humanized: humanizeResult?.ok ?? false,
|
|
259
|
+
humanizeChanges: (humanizeResult as any)?.changeCount ?? 0,
|
|
260
|
+
filePath: `${projectDir}/draft.md`,
|
|
261
|
+
projectDir,
|
|
262
|
+
pipelinePath: projectDir,
|
|
263
|
+
legacyDir: `${effectiveDataDir}/contents/${content.id}`,
|
|
264
|
+
openCommand: `open "${projectDir}"`,
|
|
265
|
+
message: [
|
|
266
|
+
`📄 内容已保存到 pipeline:`,
|
|
267
|
+
` 草稿:${projectDir}/draft.md`,
|
|
268
|
+
` 元数据:${projectDir}/meta.yaml`,
|
|
269
|
+
` 版本:${projectDir}/draft-v1.md`,
|
|
270
|
+
` 自动去AI味:${humanizeResult?.ok ? "✅ 已处理" : "⚠️ 跳过"}`,
|
|
271
|
+
``,
|
|
272
|
+
`打开文件夹:open "${projectDir}"`,
|
|
273
|
+
`查看草稿:cat "${projectDir}/draft.md"`,
|
|
274
|
+
].join("\n"),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cover Review Tool — generate, review, and approve cover images.
|
|
3
|
+
*
|
|
4
|
+
* Actions:
|
|
5
|
+
* - create_candidates: generate 3 style variants (A/B/C) as 3:4 images
|
|
6
|
+
* - get: retrieve existing cover review for a content
|
|
7
|
+
* - approve: approve a selected variant
|
|
8
|
+
* - generate_ratios: [Pro] generate 16:9 + 4:3 from approved cover
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import {
|
|
13
|
+
getContent,
|
|
14
|
+
getCoverReview,
|
|
15
|
+
saveCoverReview,
|
|
16
|
+
approveCoverVariant,
|
|
17
|
+
type CoverReview,
|
|
18
|
+
type CoverVariant,
|
|
19
|
+
} from "../storage/local-store.js";
|
|
20
|
+
import { buildCoverPrompts, type CoverPromptSet } from "../modules/cover/prompt-builder.js";
|
|
21
|
+
import { generateImage, listReferencePhotos, type GeminiModel } from "../adapters/image/gemini.js";
|
|
22
|
+
import { generateMultiRatio } from "../modules/cover/ratio-adapter.js";
|
|
23
|
+
|
|
24
|
+
type CoverLabel = "a" | "b" | "c";
|
|
25
|
+
|
|
26
|
+
export const coverReviewSchema = Type.Object({
|
|
27
|
+
action: Type.Unsafe<"create_candidates" | "get" | "approve" | "generate_ratios">({
|
|
28
|
+
type: "string",
|
|
29
|
+
enum: ["create_candidates", "get", "approve", "generate_ratios"],
|
|
30
|
+
description:
|
|
31
|
+
"Cover action: create_candidates (generate 3 covers), get (view review), approve (pick one), generate_ratios (Pro: 16:9 + 4:3).",
|
|
32
|
+
}),
|
|
33
|
+
content_id: Type.String({ description: "AutoCrew content id." }),
|
|
34
|
+
label: Type.Optional(
|
|
35
|
+
Type.Unsafe<CoverLabel>({
|
|
36
|
+
type: "string",
|
|
37
|
+
enum: ["a", "b", "c"],
|
|
38
|
+
description: "Which variant to approve (for approve action).",
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
custom_title: Type.Optional(
|
|
42
|
+
Type.String({ description: "Override the auto-extracted cover title (2-8 Chinese chars)." }),
|
|
43
|
+
),
|
|
44
|
+
_geminiApiKey: Type.Optional(Type.String()),
|
|
45
|
+
_geminiModel: Type.Optional(Type.String()),
|
|
46
|
+
_dataDir: Type.Optional(Type.String()),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function getDataDir(params: Record<string, unknown>): string {
|
|
50
|
+
if (params._dataDir) return params._dataDir as string;
|
|
51
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
52
|
+
return path.join(home, ".autocrew");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getGeminiApiKey(params: Record<string, unknown>): string | null {
|
|
56
|
+
return (params._geminiApiKey as string) || process.env.GEMINI_API_KEY || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getGeminiModel(params: Record<string, unknown>): GeminiModel {
|
|
60
|
+
const m = params._geminiModel as string;
|
|
61
|
+
if (m === "imagen-4" || m === "gemini-native") return m;
|
|
62
|
+
return "auto";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function executeCoverReview(params: Record<string, unknown>) {
|
|
66
|
+
const action = params.action as string;
|
|
67
|
+
const contentId = params.content_id as string;
|
|
68
|
+
const dataDir = getDataDir(params);
|
|
69
|
+
|
|
70
|
+
if (!contentId) return { ok: false, error: "content_id is required" };
|
|
71
|
+
|
|
72
|
+
// --- GET ---
|
|
73
|
+
if (action === "get") {
|
|
74
|
+
const review = await getCoverReview(contentId, dataDir);
|
|
75
|
+
if (!review) return { ok: false, error: `No cover review found for ${contentId}` };
|
|
76
|
+
return { ok: true, review };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- APPROVE ---
|
|
80
|
+
if (action === "approve") {
|
|
81
|
+
const label = params.label as CoverLabel;
|
|
82
|
+
if (!label) return { ok: false, error: "label (a/b/c) is required for approve action" };
|
|
83
|
+
|
|
84
|
+
const result = await approveCoverVariant(contentId, label, dataDir);
|
|
85
|
+
if (!result) return { ok: false, error: `Failed to approve variant ${label} for ${contentId}` };
|
|
86
|
+
return { ok: true, review: result };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- CREATE CANDIDATES ---
|
|
90
|
+
if (action === "create_candidates") {
|
|
91
|
+
const apiKey = getGeminiApiKey(params);
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: "Gemini API key required for cover generation.",
|
|
96
|
+
hint: "免费获取:https://aistudio.google.com/apikey — 在 OpenClaw 插件设置中填入 gemini_api_key",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = await getContent(contentId, dataDir);
|
|
101
|
+
if (!content) return { ok: false, error: `Content ${contentId} not found` };
|
|
102
|
+
|
|
103
|
+
const model = getGeminiModel(params);
|
|
104
|
+
const referencePhotos = await listReferencePhotos(dataDir);
|
|
105
|
+
|
|
106
|
+
// Build 3 prompt sets
|
|
107
|
+
const promptSets = buildCoverPrompts({
|
|
108
|
+
title: content.title,
|
|
109
|
+
body: content.body,
|
|
110
|
+
platform: content.platform,
|
|
111
|
+
hasReferencePhotos: referencePhotos.length > 0,
|
|
112
|
+
customTitle: params.custom_title as string | undefined,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Generate 3 images
|
|
116
|
+
const assetsDir = path.join(dataDir, "contents", contentId, "assets", "covers");
|
|
117
|
+
const variants: CoverVariant[] = [];
|
|
118
|
+
const errors: string[] = [];
|
|
119
|
+
|
|
120
|
+
for (const ps of promptSets) {
|
|
121
|
+
const outputPath = path.join(assetsDir, `cover-${ps.label.toLowerCase()}`);
|
|
122
|
+
const result = await generateImage({
|
|
123
|
+
prompt: ps.imagePrompt,
|
|
124
|
+
aspectRatio: "3:4",
|
|
125
|
+
model,
|
|
126
|
+
apiKey,
|
|
127
|
+
referenceImagePaths: referencePhotos.length > 0 ? referencePhotos : undefined,
|
|
128
|
+
outputPath,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (result.ok) {
|
|
132
|
+
variants.push({
|
|
133
|
+
label: ps.label.toLowerCase() as CoverLabel,
|
|
134
|
+
imagePrompt: ps.imagePrompt,
|
|
135
|
+
style: ps.style,
|
|
136
|
+
titleText: ps.titleText,
|
|
137
|
+
imagePaths: { "3:4": result.imagePath },
|
|
138
|
+
model: result.model,
|
|
139
|
+
hasPersonalIP: referencePhotos.length > 0,
|
|
140
|
+
layoutHint: ps.layoutHint,
|
|
141
|
+
designReason: `${ps.style} 风格 — ${ps.layoutHint.slice(0, 60)}`,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
errors.push(`${ps.label}: ${result.error}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (variants.length === 0) {
|
|
149
|
+
return { ok: false, error: "All 3 cover generations failed", details: errors };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Save cover review
|
|
153
|
+
const review: CoverReview = {
|
|
154
|
+
platform: content.platform || "xhs",
|
|
155
|
+
status: "review_pending",
|
|
156
|
+
variants,
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const saved = await saveCoverReview(contentId, review, dataDir);
|
|
161
|
+
if (!saved) return { ok: false, error: "Failed to save cover review" };
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
review,
|
|
166
|
+
generated: variants.length,
|
|
167
|
+
failed: errors.length,
|
|
168
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- GENERATE RATIOS (Pro) ---
|
|
173
|
+
if (action === "generate_ratios") {
|
|
174
|
+
const apiKey = getGeminiApiKey(params);
|
|
175
|
+
if (!apiKey) {
|
|
176
|
+
return { ok: false, error: "Gemini API key required." };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const review = await getCoverReview(contentId, dataDir);
|
|
180
|
+
if (!review) return { ok: false, error: `No cover review found for ${contentId}` };
|
|
181
|
+
if (!review.approvedLabel) return { ok: false, error: "No variant approved yet. Run approve first." };
|
|
182
|
+
|
|
183
|
+
const approved = review.variants.find((v) => v.label === review.approvedLabel);
|
|
184
|
+
if (!approved?.imagePrompt) return { ok: false, error: "Approved variant has no prompt" };
|
|
185
|
+
|
|
186
|
+
const model = getGeminiModel(params);
|
|
187
|
+
const referencePhotos = approved.hasPersonalIP ? await listReferencePhotos(dataDir) : undefined;
|
|
188
|
+
const assetsDir = path.join(dataDir, "contents", contentId, "assets", "covers");
|
|
189
|
+
|
|
190
|
+
const result = await generateMultiRatio({
|
|
191
|
+
originalPrompt: approved.imagePrompt,
|
|
192
|
+
apiKey,
|
|
193
|
+
model,
|
|
194
|
+
referenceImagePaths: referencePhotos,
|
|
195
|
+
outputDir: assetsDir,
|
|
196
|
+
baseName: `cover-${approved.label}`,
|
|
197
|
+
dataDir,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Pro gate returned upgrade hint
|
|
201
|
+
if ("upgradeHint" in result) return result;
|
|
202
|
+
|
|
203
|
+
// Update variant with new paths
|
|
204
|
+
if (result.paths["16:9"]) {
|
|
205
|
+
approved.imagePaths = { ...approved.imagePaths, "16:9": result.paths["16:9"] };
|
|
206
|
+
}
|
|
207
|
+
if (result.paths["4:3"]) {
|
|
208
|
+
approved.imagePaths = { ...approved.imagePaths, "4:3": result.paths["4:3"] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await saveCoverReview(contentId, review, dataDir);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
ok: result.ok,
|
|
215
|
+
paths: result.paths,
|
|
216
|
+
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
221
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { getContent, updateContent } from "../storage/local-store.js";
|
|
3
|
+
import { humanizeZh } from "../modules/humanizer/zh.js";
|
|
4
|
+
|
|
5
|
+
export const humanizeSchema = Type.Object({
|
|
6
|
+
action: Type.Unsafe<"humanize_zh">({
|
|
7
|
+
type: "string",
|
|
8
|
+
enum: ["humanize_zh"],
|
|
9
|
+
description: "Action. Currently supports 'humanize_zh'.",
|
|
10
|
+
}),
|
|
11
|
+
content_id: Type.Optional(Type.String({ description: "AutoCrew content id to humanize and optionally save back." })),
|
|
12
|
+
text: Type.Optional(Type.String({ description: "Raw text to humanize directly." })),
|
|
13
|
+
save_back: Type.Optional(Type.Boolean({ description: "When content_id is provided, save the humanized text back into the draft." })),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export async function executeHumanize(params: Record<string, unknown>) {
|
|
17
|
+
const action = params.action as string;
|
|
18
|
+
if (action !== "humanize_zh") {
|
|
19
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dataDir = (params._dataDir as string) || undefined;
|
|
23
|
+
let title = "";
|
|
24
|
+
let text = (params.text as string) || "";
|
|
25
|
+
const contentId = params.content_id as string | undefined;
|
|
26
|
+
|
|
27
|
+
if (!text && contentId) {
|
|
28
|
+
const content = await getContent(contentId, dataDir);
|
|
29
|
+
if (!content) {
|
|
30
|
+
return { ok: false, error: `Content ${contentId} not found` };
|
|
31
|
+
}
|
|
32
|
+
title = content.title;
|
|
33
|
+
text = content.body;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!text) {
|
|
37
|
+
return { ok: false, error: "text or content_id is required" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = humanizeZh({ text });
|
|
41
|
+
if (contentId && params.save_back) {
|
|
42
|
+
const updated = await updateContent(
|
|
43
|
+
contentId,
|
|
44
|
+
{
|
|
45
|
+
title: title || undefined,
|
|
46
|
+
body: result.humanizedText,
|
|
47
|
+
},
|
|
48
|
+
dataDir,
|
|
49
|
+
);
|
|
50
|
+
return { ...result, content: updated };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|