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.
Files changed (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ initProfile,
7
+ loadProfile,
8
+ saveProfile,
9
+ updateProfile,
10
+ addWritingRule,
11
+ addCompetitor,
12
+ detectMissingInfo,
13
+ type CreatorProfile,
14
+ } from "../profile/creator-profile.js";
15
+
16
+ let testDir: string;
17
+
18
+ beforeEach(async () => {
19
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-profile-test-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await fs.rm(testDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("initProfile", () => {
27
+ it("creates a new empty profile", async () => {
28
+ const profile = await initProfile(testDir);
29
+ expect(profile.industry).toBe("");
30
+ expect(profile.platforms).toEqual([]);
31
+ expect(profile.styleCalibrated).toBe(false);
32
+ expect(profile.writingRules).toEqual([]);
33
+ });
34
+
35
+ it("is idempotent — does not overwrite existing profile", async () => {
36
+ await initProfile(testDir);
37
+ // Manually update
38
+ const existing = await loadProfile(testDir);
39
+ existing!.industry = "科技";
40
+ await saveProfile(existing!, testDir);
41
+
42
+ // Init again — should not overwrite
43
+ const profile = await initProfile(testDir);
44
+ expect(profile.industry).toBe("科技");
45
+ });
46
+ });
47
+
48
+ describe("loadProfile", () => {
49
+ it("returns null when profile does not exist", async () => {
50
+ const profile = await loadProfile(testDir);
51
+ expect(profile).toBeNull();
52
+ });
53
+
54
+ it("loads a saved profile", async () => {
55
+ await initProfile(testDir);
56
+ const profile = await loadProfile(testDir);
57
+ expect(profile).not.toBeNull();
58
+ expect(profile!.createdAt).toBeTruthy();
59
+ });
60
+ });
61
+
62
+ describe("updateProfile", () => {
63
+ it("merges partial updates", async () => {
64
+ await initProfile(testDir);
65
+ const updated = await updateProfile({ industry: "美妆", platforms: ["xhs", "douyin"] }, testDir);
66
+ expect(updated.industry).toBe("美妆");
67
+ expect(updated.platforms).toEqual(["xhs", "douyin"]);
68
+ });
69
+
70
+ it("preserves fields not in the update", async () => {
71
+ await initProfile(testDir);
72
+ await updateProfile({ industry: "科技" }, testDir);
73
+ const updated = await updateProfile({ platforms: ["xhs"] }, testDir);
74
+ expect(updated.industry).toBe("科技");
75
+ expect(updated.platforms).toEqual(["xhs"]);
76
+ });
77
+
78
+ it("updates updatedAt timestamp", async () => {
79
+ const profile = await initProfile(testDir);
80
+ const before = profile.updatedAt;
81
+ await new Promise((r) => setTimeout(r, 5));
82
+ const updated = await updateProfile({ industry: "教育" }, testDir);
83
+ expect(updated.updatedAt).not.toBe(before);
84
+ });
85
+ });
86
+
87
+ describe("addWritingRule", () => {
88
+ it("adds a new rule", async () => {
89
+ await initProfile(testDir);
90
+ const profile = await addWritingRule(
91
+ { rule: "禁用顺序词", source: "auto_distilled", confidence: 0.9 },
92
+ testDir,
93
+ );
94
+ expect(profile.writingRules).toHaveLength(1);
95
+ expect(profile.writingRules[0].rule).toBe("禁用顺序词");
96
+ });
97
+
98
+ it("deduplicates rules by text", async () => {
99
+ await initProfile(testDir);
100
+ await addWritingRule({ rule: "禁用顺序词", source: "auto_distilled", confidence: 0.9 }, testDir);
101
+ const profile = await addWritingRule(
102
+ { rule: "禁用顺序词", source: "user_explicit", confidence: 1.0 },
103
+ testDir,
104
+ );
105
+ expect(profile.writingRules).toHaveLength(1);
106
+ });
107
+
108
+ it("adds multiple distinct rules", async () => {
109
+ await initProfile(testDir);
110
+ await addWritingRule({ rule: "规则A", source: "auto_distilled", confidence: 0.8 }, testDir);
111
+ const profile = await addWritingRule(
112
+ { rule: "规则B", source: "user_explicit", confidence: 1.0 },
113
+ testDir,
114
+ );
115
+ expect(profile.writingRules).toHaveLength(2);
116
+ });
117
+ });
118
+
119
+ describe("addCompetitor", () => {
120
+ it("adds a competitor account", async () => {
121
+ await initProfile(testDir);
122
+ const profile = await addCompetitor(
123
+ { platform: "xhs", profileUrl: "https://xhs.com/user/123", name: "测试账号" },
124
+ testDir,
125
+ );
126
+ expect(profile.competitorAccounts).toHaveLength(1);
127
+ expect(profile.competitorAccounts[0].name).toBe("测试账号");
128
+ });
129
+
130
+ it("deduplicates by profileUrl", async () => {
131
+ await initProfile(testDir);
132
+ await addCompetitor(
133
+ { platform: "xhs", profileUrl: "https://xhs.com/user/123", name: "账号A" },
134
+ testDir,
135
+ );
136
+ const profile = await addCompetitor(
137
+ { platform: "xhs", profileUrl: "https://xhs.com/user/123", name: "账号B" },
138
+ testDir,
139
+ );
140
+ expect(profile.competitorAccounts).toHaveLength(1);
141
+ expect(profile.competitorAccounts[0].name).toBe("账号A");
142
+ });
143
+ });
144
+
145
+ describe("detectMissingInfo", () => {
146
+ it("reports all missing fields on empty profile", async () => {
147
+ const profile = await initProfile(testDir);
148
+ const missing = detectMissingInfo(profile);
149
+ expect(missing).toContain("industry");
150
+ expect(missing).toContain("platforms");
151
+ expect(missing).toContain("audience");
152
+ expect(missing).toContain("style");
153
+ });
154
+
155
+ it("reports nothing when profile is complete", async () => {
156
+ const profile = await initProfile(testDir);
157
+ const complete: CreatorProfile = {
158
+ ...profile,
159
+ industry: "科技",
160
+ platforms: ["xhs"],
161
+ audiencePersona: { name: "职场人", painPoints: [] },
162
+ styleCalibrated: true,
163
+ };
164
+ const missing = detectMissingInfo(complete);
165
+ expect(missing).toHaveLength(0);
166
+ });
167
+
168
+ it("reports only missing fields", async () => {
169
+ const profile = await initProfile(testDir);
170
+ await updateProfile({ industry: "美妆", platforms: ["xhs"] }, testDir);
171
+ const updated = await loadProfile(testDir);
172
+ const missing = detectMissingInfo(updated!);
173
+ expect(missing).not.toContain("industry");
174
+ expect(missing).not.toContain("platforms");
175
+ expect(missing).toContain("audience");
176
+ expect(missing).toContain("style");
177
+ });
178
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Creator Profile — Structured creator persona stored at ~/.autocrew/creator-profile.json
3
+ *
4
+ * This is the core identity file that drives personalized writing, topic scoring,
5
+ * and style calibration. It's initialized during onboarding (from host MEMORY or
6
+ * by asking the user) and continuously enriched by the Learnings system.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+
11
+ export interface WritingRule {
12
+ rule: string;
13
+ /** "auto_distilled" = extracted from user edits, "user_explicit" = user stated directly */
14
+ source: "auto_distilled" | "user_explicit";
15
+ /** 0-1, higher = more confident */
16
+ confidence: number;
17
+ createdAt: string;
18
+ }
19
+
20
+ export interface AudiencePersona {
21
+ name: string;
22
+ age?: string;
23
+ job?: string;
24
+ painPoints: string[];
25
+ scrollStopTriggers?: string[];
26
+ }
27
+
28
+ export interface CompetitorAccount {
29
+ platform: string;
30
+ profileUrl: string;
31
+ name: string;
32
+ addedAt: string;
33
+ }
34
+
35
+ export interface PerformanceEntry {
36
+ contentId: string;
37
+ platform: string;
38
+ metrics: Record<string, number>;
39
+ recordedAt: string;
40
+ }
41
+
42
+ export interface CreatorPersona {
43
+ /** Creator personality type: thought_leader, storyteller, analyst, curator, entertainer */
44
+ type: string;
45
+ /** One-line unique angle — what makes this creator different */
46
+ uniqueAngle: string;
47
+ /** Content goals: growth, monetization, branding, community */
48
+ contentGoals: string[];
49
+ /** Core expertise areas */
50
+ expertise: string[];
51
+ /** Why audience follows this creator (not competitors) */
52
+ audienceResonance: string;
53
+ /** Creator's blind spots or growth areas */
54
+ growthAreas: string[];
55
+ }
56
+
57
+ export interface ContentPillar {
58
+ name: string;
59
+ targetPersona: string;
60
+ valueProposition: string;
61
+ contentRatio: number;
62
+ toneGuide: string;
63
+ exampleAngles: string[];
64
+ }
65
+
66
+ export interface CreatorProfile {
67
+ /** User's content industry/niche */
68
+ industry: string;
69
+ /** Active platforms */
70
+ platforms: string[];
71
+ /** Target audience persona */
72
+ audiencePersona: AudiencePersona | null;
73
+ /** Creator personality profile (from calibration) */
74
+ creatorPersona: CreatorPersona | null;
75
+ /** Auto-distilled + user-explicit writing rules */
76
+ writingRules: WritingRule[];
77
+ /** Style boundaries */
78
+ styleBoundaries: { never: string[]; always: string[] };
79
+ /** Competitor accounts (Pro) */
80
+ competitorAccounts: CompetitorAccount[];
81
+ /** Historical performance data points */
82
+ performanceHistory: PerformanceEntry[];
83
+ /** Natural language description of how this creator writes (from calibration) */
84
+ expressionPersona: string;
85
+ /** Additional audience personas (from calibration Phase 0.5) */
86
+ secondaryPersonas: AudiencePersona[];
87
+ /** Content pillars defining the creator's content strategy */
88
+ contentPillars?: ContentPillar[];
89
+ /** Whether style calibration has been completed */
90
+ styleCalibrated: boolean;
91
+ /** Profile creation timestamp */
92
+ createdAt: string;
93
+ /** Last update timestamp */
94
+ updatedAt: string;
95
+ }
96
+
97
+ const PROFILE_FILE = "creator-profile.json";
98
+
99
+ function getDataDir(customDir?: string): string {
100
+ if (customDir) return customDir;
101
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
102
+ return path.join(home, ".autocrew");
103
+ }
104
+
105
+ function emptyProfile(): CreatorProfile {
106
+ const now = new Date().toISOString();
107
+ return {
108
+ industry: "",
109
+ platforms: [],
110
+ audiencePersona: null,
111
+ creatorPersona: null,
112
+ writingRules: [],
113
+ styleBoundaries: { never: [], always: [] },
114
+ competitorAccounts: [],
115
+ performanceHistory: [],
116
+ expressionPersona: "",
117
+ secondaryPersonas: [],
118
+ styleCalibrated: false,
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Check if creator-profile.json exists.
126
+ */
127
+ export async function profileExists(dataDir?: string): Promise<boolean> {
128
+ const filePath = path.join(getDataDir(dataDir), PROFILE_FILE);
129
+ try {
130
+ await fs.access(filePath);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Load the creator profile. Returns null if it doesn't exist.
139
+ */
140
+ export async function loadProfile(dataDir?: string): Promise<CreatorProfile | null> {
141
+ const filePath = path.join(getDataDir(dataDir), PROFILE_FILE);
142
+ try {
143
+ const raw = await fs.readFile(filePath, "utf-8");
144
+ return JSON.parse(raw) as CreatorProfile;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Save the full creator profile (overwrite).
152
+ */
153
+ export async function saveProfile(profile: CreatorProfile, dataDir?: string): Promise<void> {
154
+ const dir = getDataDir(dataDir);
155
+ await fs.mkdir(dir, { recursive: true });
156
+ profile.updatedAt = new Date().toISOString();
157
+ await fs.writeFile(path.join(dir, PROFILE_FILE), JSON.stringify(profile, null, 2), "utf-8");
158
+ }
159
+
160
+ /**
161
+ * Initialize a new empty profile. No-op if one already exists.
162
+ * Returns the profile (existing or newly created).
163
+ */
164
+ export async function initProfile(dataDir?: string): Promise<CreatorProfile> {
165
+ const existing = await loadProfile(dataDir);
166
+ if (existing) return existing;
167
+ const profile = emptyProfile();
168
+ await saveProfile(profile, dataDir);
169
+ return profile;
170
+ }
171
+
172
+ /**
173
+ * Partially update the profile (merge fields).
174
+ */
175
+ export async function updateProfile(
176
+ updates: Partial<Omit<CreatorProfile, "createdAt" | "updatedAt">>,
177
+ dataDir?: string,
178
+ ): Promise<CreatorProfile> {
179
+ let profile = await loadProfile(dataDir);
180
+ if (!profile) profile = emptyProfile();
181
+
182
+ const merged: CreatorProfile = {
183
+ ...profile,
184
+ ...updates,
185
+ // Arrays: replace entirely if provided, keep existing otherwise
186
+ writingRules: updates.writingRules ?? profile.writingRules,
187
+ competitorAccounts: updates.competitorAccounts ?? profile.competitorAccounts,
188
+ performanceHistory: updates.performanceHistory ?? profile.performanceHistory,
189
+ // Preserve immutable fields
190
+ createdAt: profile.createdAt,
191
+ updatedAt: new Date().toISOString(),
192
+ };
193
+
194
+ await saveProfile(merged, dataDir);
195
+ return merged;
196
+ }
197
+
198
+ /**
199
+ * Add a writing rule (deduplicates by rule text).
200
+ */
201
+ export async function addWritingRule(rule: Omit<WritingRule, "createdAt">, dataDir?: string): Promise<CreatorProfile> {
202
+ const profile = (await loadProfile(dataDir)) || emptyProfile();
203
+ const exists = profile.writingRules.some((r) => r.rule === rule.rule);
204
+ if (!exists) {
205
+ profile.writingRules.push({ ...rule, createdAt: new Date().toISOString() });
206
+ }
207
+ await saveProfile(profile, dataDir);
208
+ return profile;
209
+ }
210
+
211
+ /**
212
+ * Add a competitor account (deduplicates by profileUrl).
213
+ */
214
+ export async function addCompetitor(account: Omit<CompetitorAccount, "addedAt">, dataDir?: string): Promise<CreatorProfile> {
215
+ const profile = (await loadProfile(dataDir)) || emptyProfile();
216
+ const exists = profile.competitorAccounts.some((c) => c.profileUrl === account.profileUrl);
217
+ if (!exists) {
218
+ profile.competitorAccounts.push({ ...account, addedAt: new Date().toISOString() });
219
+ }
220
+ await saveProfile(profile, dataDir);
221
+ return profile;
222
+ }
223
+
224
+ /**
225
+ * Record a performance data point.
226
+ */
227
+ export async function addPerformanceEntry(entry: Omit<PerformanceEntry, "recordedAt">, dataDir?: string): Promise<void> {
228
+ const profile = (await loadProfile(dataDir)) || emptyProfile();
229
+ profile.performanceHistory.push({ ...entry, recordedAt: new Date().toISOString() });
230
+ // Keep last 100 entries
231
+ if (profile.performanceHistory.length > 100) {
232
+ profile.performanceHistory = profile.performanceHistory.slice(-100);
233
+ }
234
+ await saveProfile(profile, dataDir);
235
+ }
236
+
237
+ /**
238
+ * Detect what information is missing from the profile.
239
+ * Used by the onboarding skill to decide what to ask.
240
+ */
241
+ export function detectMissingInfo(profile: CreatorProfile): string[] {
242
+ const missing: string[] = [];
243
+ if (!profile.industry) missing.push("industry");
244
+ if (profile.platforms.length === 0) missing.push("platforms");
245
+ if (!profile.audiencePersona) missing.push("audience");
246
+ if (!profile.styleCalibrated) missing.push("style");
247
+ return missing;
248
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Douyin (抖音) publisher — placeholder module.
3
+ *
4
+ * Implementation should use the Douyin Creator Open Platform API:
5
+ * https://open.douyin.com/platform/doc/6848806527751489550
6
+ *
7
+ * Required capabilities:
8
+ * - Video upload via /video/upload/
9
+ * - Content publishing via /video/create/
10
+ * - OAuth 2.0 token management
11
+ */
12
+
13
+ export interface DouyinPublishOptions {
14
+ title: string;
15
+ description: string;
16
+ videoPath?: string;
17
+ imagePaths?: string[];
18
+ isPrivate?: boolean;
19
+ postTime?: string;
20
+ }
21
+
22
+ export interface DouyinPublishResult {
23
+ ok: boolean;
24
+ itemId?: string;
25
+ url?: string;
26
+ error?: string;
27
+ }
28
+
29
+ export async function publishToDouyin(_options: DouyinPublishOptions): Promise<DouyinPublishResult> {
30
+ return {
31
+ ok: false,
32
+ error: "Douyin publishing is not yet implemented. Planned: Douyin Creator Open Platform API integration.",
33
+ };
34
+ }