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,189 @@
1
+ /**
2
+ * Diff Tracker — records before/after diffs when users edit content
3
+ *
4
+ * Stores diffs in ~/.autocrew/learnings/edits/ as JSON files.
5
+ * Each diff captures: contentId, field changed, before/after text, timestamp.
6
+ * These diffs feed into the Rule Distiller for pattern recognition.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+
11
+ export interface EditDiff {
12
+ id: string;
13
+ contentId: string;
14
+ field: "body" | "title" | "hashtags" | "other";
15
+ before: string;
16
+ after: string;
17
+ /** What specifically changed — short description */
18
+ changeType?: string;
19
+ /** Detected patterns in this edit */
20
+ patterns: string[];
21
+ createdAt: string;
22
+ }
23
+
24
+ export interface DiffAnalysis {
25
+ /** Detected edit patterns */
26
+ patterns: string[];
27
+ /** Short human-readable summary */
28
+ summary: string;
29
+ }
30
+
31
+ function getDataDir(customDir?: string): string {
32
+ if (customDir) return customDir;
33
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
34
+ return path.join(home, ".autocrew");
35
+ }
36
+
37
+ async function editsDir(dataDir?: string): Promise<string> {
38
+ const dir = path.join(getDataDir(dataDir), "learnings", "edits");
39
+ await fs.mkdir(dir, { recursive: true });
40
+ return dir;
41
+ }
42
+
43
+ /**
44
+ * Record a content edit diff.
45
+ */
46
+ export async function recordDiff(
47
+ contentId: string,
48
+ field: EditDiff["field"],
49
+ before: string,
50
+ after: string,
51
+ dataDir?: string,
52
+ ): Promise<EditDiff> {
53
+ const dir = await editsDir(dataDir);
54
+ const id = `diff-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
55
+ const patterns = detectPatterns(before, after);
56
+
57
+ const diff: EditDiff = {
58
+ id,
59
+ contentId,
60
+ field,
61
+ before: before.slice(0, 2000),
62
+ after: after.slice(0, 2000),
63
+ patterns,
64
+ createdAt: new Date().toISOString(),
65
+ };
66
+
67
+ await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(diff, null, 2), "utf-8");
68
+ return diff;
69
+ }
70
+
71
+ /**
72
+ * List all recorded diffs, optionally filtered by contentId.
73
+ */
74
+ export async function listDiffs(
75
+ opts?: { contentId?: string; limit?: number },
76
+ dataDir?: string,
77
+ ): Promise<EditDiff[]> {
78
+ const dir = await editsDir(dataDir);
79
+ let files: string[];
80
+ try {
81
+ files = await fs.readdir(dir);
82
+ } catch {
83
+ return [];
84
+ }
85
+
86
+ const diffs: EditDiff[] = [];
87
+ for (const f of files) {
88
+ if (!f.endsWith(".json")) continue;
89
+ try {
90
+ const raw = await fs.readFile(path.join(dir, f), "utf-8");
91
+ const diff: EditDiff = JSON.parse(raw);
92
+ if (opts?.contentId && diff.contentId !== opts.contentId) continue;
93
+ diffs.push(diff);
94
+ } catch {
95
+ /* skip corrupted files */
96
+ }
97
+ }
98
+
99
+ diffs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
100
+ if (opts?.limit) return diffs.slice(0, opts.limit);
101
+ return diffs;
102
+ }
103
+
104
+ /**
105
+ * Analyze a before/after pair to detect common edit patterns.
106
+ */
107
+ export function detectPatterns(before: string, after: string): string[] {
108
+ const patterns: string[] = [];
109
+
110
+ // Pattern: removed progression words (首先/其次/最后)
111
+ const progressionBefore = (before.match(/首先|其次|最后|第一|第二|第三/g) || []).length;
112
+ const progressionAfter = (after.match(/首先|其次|最后|第一|第二|第三/g) || []).length;
113
+ if (progressionBefore > progressionAfter && progressionBefore - progressionAfter >= 2) {
114
+ patterns.push("remove_progression_words");
115
+ }
116
+
117
+ // Pattern: shortened paragraphs
118
+ const parasBefore = before.split(/\n{2,}/).filter((p) => p.trim());
119
+ const parasAfter = after.split(/\n{2,}/).filter((p) => p.trim());
120
+ if (parasAfter.length > parasBefore.length && after.length <= before.length) {
121
+ patterns.push("break_long_paragraphs");
122
+ }
123
+
124
+ // Pattern: removed AI-style phrases
125
+ const aiPhrases = /值得一提的是|需要注意的是|综上所述|总而言之|总的来说|可以说|毫不夸张地说|赋能|助力|打通|闭环|深度|全方位|多维度/g;
126
+ const aiBefore = (before.match(aiPhrases) || []).length;
127
+ const aiAfter = (after.match(aiPhrases) || []).length;
128
+ if (aiBefore > aiAfter && aiBefore - aiAfter >= 2) {
129
+ patterns.push("remove_ai_phrases");
130
+ }
131
+
132
+ // Pattern: added colloquial expressions
133
+ const colloquial = /说白了|你想啊|问题来了|讲真|说实话|老实说|不瞒你说|坦白讲/g;
134
+ const collBefore = (before.match(colloquial) || []).length;
135
+ const collAfter = (after.match(colloquial) || []).length;
136
+ if (collAfter > collBefore) {
137
+ patterns.push("add_colloquial_tone");
138
+ }
139
+
140
+ // Pattern: reduced "我们" usage
141
+ const weBefore = (before.match(/我们/g) || []).length;
142
+ const weAfter = (after.match(/我们/g) || []).length;
143
+ if (weBefore > weAfter && weBefore - weAfter >= 2) {
144
+ patterns.push("reduce_we_pronoun");
145
+ }
146
+
147
+ // Pattern: shortened overall length
148
+ if (after.length < before.length * 0.8) {
149
+ patterns.push("shorten_content");
150
+ }
151
+
152
+ // Pattern: added emoji
153
+ const emojiBefore = (before.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
154
+ const emojiAfter = (after.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
155
+ if (emojiAfter > emojiBefore + 2) {
156
+ patterns.push("add_emoji");
157
+ }
158
+
159
+ // Pattern: replaced formal words with casual
160
+ const formalWords = /因此|然而|此外|尽管|虽然|但是|不过/g;
161
+ const formalBefore = (before.match(formalWords) || []).length;
162
+ const formalAfter = (after.match(formalWords) || []).length;
163
+ if (formalBefore > formalAfter && formalBefore - formalAfter >= 2) {
164
+ patterns.push("casualize_tone");
165
+ }
166
+
167
+ return patterns;
168
+ }
169
+
170
+ /**
171
+ * Get pattern frequency across all diffs.
172
+ * Returns patterns sorted by frequency (most common first).
173
+ */
174
+ export async function getPatternFrequency(
175
+ dataDir?: string,
176
+ ): Promise<Array<{ pattern: string; count: number }>> {
177
+ const diffs = await listDiffs(undefined, dataDir);
178
+ const freq = new Map<string, number>();
179
+
180
+ for (const diff of diffs) {
181
+ for (const p of diff.patterns) {
182
+ freq.set(p, (freq.get(p) || 0) + 1);
183
+ }
184
+ }
185
+
186
+ return Array.from(freq.entries())
187
+ .map(([pattern, count]) => ({ pattern, count }))
188
+ .sort((a, b) => b.count - a.count);
189
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Rule Distiller — automatically extracts writing rules from accumulated edit patterns
3
+ *
4
+ * When a pattern appears 5+ times in user edits, it gets distilled into a
5
+ * WritingRule and written to creator-profile.json.
6
+ *
7
+ * PRD §7: "累积 5+ 次同类修改后,自动提炼为规则"
8
+ */
9
+ import { getPatternFrequency } from "./diff-tracker.js";
10
+ import {
11
+ loadProfile,
12
+ addWritingRule,
13
+ type WritingRule,
14
+ } from "../profile/creator-profile.js";
15
+
16
+ export interface DistillResult {
17
+ /** Number of new rules distilled this run */
18
+ newRulesCount: number;
19
+ /** The new rules that were added */
20
+ newRules: WritingRule[];
21
+ /** Patterns that are close to threshold (3-4 occurrences) */
22
+ emerging: Array<{ pattern: string; count: number; remaining: number }>;
23
+ /** Summary for display */
24
+ summary: string;
25
+ }
26
+
27
+ /** Minimum occurrences before a pattern becomes a rule */
28
+ const DISTILL_THRESHOLD = 5;
29
+
30
+ /** Map pattern IDs to human-readable rule descriptions */
31
+ const PATTERN_TO_RULE: Record<string, string> = {
32
+ remove_progression_words: "禁用顺序词(首先/其次/最后/第一/第二/第三)",
33
+ break_long_paragraphs: "长段落拆分为短段落,每段不超过 3-4 行",
34
+ remove_ai_phrases: "删除 AI 味套话(值得一提、综上所述、赋能、闭环等)",
35
+ add_colloquial_tone: "适当加入口语化表达(说白了、讲真、你想啊)",
36
+ reduce_we_pronoun: '减少"我们"开头,多用"你"拉近距离',
37
+ shorten_content: "精简内容,删除冗余信息",
38
+ add_emoji: "适当使用 emoji 增加可读性",
39
+ casualize_tone: "用口语化词汇替代书面语(因此→所以,然而→但是)",
40
+ };
41
+
42
+ /**
43
+ * Run the distillation process:
44
+ * 1. Get pattern frequencies from diff tracker
45
+ * 2. Check which patterns have reached the threshold
46
+ * 3. Skip patterns that already have corresponding rules
47
+ * 4. Add new rules to creator-profile.json
48
+ */
49
+ export async function distillRules(dataDir?: string): Promise<DistillResult> {
50
+ const frequencies = await getPatternFrequency(dataDir);
51
+ const profile = await loadProfile(dataDir);
52
+ const existingRules = profile?.writingRules || [];
53
+
54
+ const newRules: WritingRule[] = [];
55
+ const emerging: DistillResult["emerging"] = [];
56
+
57
+ for (const { pattern, count } of frequencies) {
58
+ const ruleText = PATTERN_TO_RULE[pattern];
59
+ if (!ruleText) continue; // Unknown pattern, skip
60
+
61
+ // Check if rule already exists
62
+ const alreadyExists = existingRules.some(
63
+ (r) => r.rule === ruleText || r.rule.includes(pattern),
64
+ );
65
+ if (alreadyExists) continue;
66
+
67
+ if (count >= DISTILL_THRESHOLD) {
68
+ // Distill into a rule
69
+ const confidence = Math.min(0.95, 0.5 + count * 0.05);
70
+ const rule = await addWritingRule(
71
+ { rule: ruleText, source: "auto_distilled", confidence },
72
+ dataDir,
73
+ );
74
+ const addedRule = rule.writingRules[rule.writingRules.length - 1];
75
+ newRules.push(addedRule);
76
+ } else if (count >= 3) {
77
+ // Emerging pattern — close to threshold
78
+ emerging.push({
79
+ pattern,
80
+ count,
81
+ remaining: DISTILL_THRESHOLD - count,
82
+ });
83
+ }
84
+ }
85
+
86
+ const summary = buildSummary(newRules, emerging);
87
+ return {
88
+ newRulesCount: newRules.length,
89
+ newRules,
90
+ emerging,
91
+ summary,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Check if distillation should run (called after each content edit).
97
+ * Returns true if there are patterns at or above threshold that haven't been distilled.
98
+ */
99
+ export async function shouldDistill(dataDir?: string): Promise<boolean> {
100
+ const frequencies = await getPatternFrequency(dataDir);
101
+ const profile = await loadProfile(dataDir);
102
+ const existingRules = profile?.writingRules || [];
103
+
104
+ for (const { pattern, count } of frequencies) {
105
+ if (count < DISTILL_THRESHOLD) continue;
106
+ const ruleText = PATTERN_TO_RULE[pattern];
107
+ if (!ruleText) continue;
108
+ const alreadyExists = existingRules.some(
109
+ (r) => r.rule === ruleText || r.rule.includes(pattern),
110
+ );
111
+ if (!alreadyExists) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function buildSummary(
117
+ newRules: WritingRule[],
118
+ emerging: DistillResult["emerging"],
119
+ ): string {
120
+ const parts: string[] = [];
121
+
122
+ if (newRules.length > 0) {
123
+ parts.push(`🎯 提炼了 ${newRules.length} 条新规则:`);
124
+ for (const r of newRules) {
125
+ parts.push(` - ${r.rule}(置信度 ${r.confidence})`);
126
+ }
127
+ }
128
+
129
+ if (emerging.length > 0) {
130
+ parts.push(`📊 接近提炼阈值的模式:`);
131
+ for (const e of emerging) {
132
+ parts.push(` - ${PATTERN_TO_RULE[e.pattern] || e.pattern}(还差 ${e.remaining} 次)`);
133
+ }
134
+ }
135
+
136
+ if (parts.length === 0) {
137
+ return "暂无可提炼的规则";
138
+ }
139
+
140
+ return parts.join("\n");
141
+ }
@@ -0,0 +1,208 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import fs from "node:fs/promises";
4
+
5
+ export type MemorySignalType =
6
+ | "approval"
7
+ | "rejection"
8
+ | "edit"
9
+ | "performance"
10
+ | "general";
11
+
12
+ export interface DistillMemoryOptions {
13
+ signalType: MemorySignalType;
14
+ feedback?: string;
15
+ originalText?: string;
16
+ modifiedText?: string;
17
+ contentTitle?: string;
18
+ platform?: string;
19
+ dataDir?: string;
20
+ }
21
+
22
+ export interface DistillMemoryResult {
23
+ ok: boolean;
24
+ section: string;
25
+ learning: string;
26
+ memoryPath: string;
27
+ archivePath: string;
28
+ }
29
+
30
+ const MEMORY_TEMPLATE = `# AutoCrew Memory
31
+
32
+ ## Brand Context
33
+
34
+ ## Writing Preferences
35
+
36
+ ## Content Edit Preferences
37
+
38
+ ## Performance Insights
39
+ `;
40
+
41
+ function resolveDataDir(customDir?: string): string {
42
+ if (customDir) return customDir;
43
+ return path.join(os.homedir(), ".autocrew");
44
+ }
45
+
46
+ async function ensureMemoryFiles(dataDir?: string) {
47
+ const root = resolveDataDir(dataDir);
48
+ const memoryPath = path.join(root, "MEMORY.md");
49
+ const archiveDir = path.join(root, "memory");
50
+ await fs.mkdir(archiveDir, { recursive: true });
51
+ try {
52
+ await fs.access(memoryPath);
53
+ } catch {
54
+ await fs.writeFile(memoryPath, MEMORY_TEMPLATE, "utf-8");
55
+ }
56
+ return { root, memoryPath, archiveDir };
57
+ }
58
+
59
+ function today(): string {
60
+ return new Date().toISOString().slice(0, 10);
61
+ }
62
+
63
+ function inferFromFeedback(feedback: string): { section: string; learning: string } | null {
64
+ const normalized = feedback.trim();
65
+ if (!normalized) return null;
66
+
67
+ if (/口语|人话|自然|书面腔|太正式/.test(normalized)) {
68
+ return {
69
+ section: "Writing Preferences",
70
+ learning: "写作偏好更口语、更自然,减少书面腔和过度正式表达",
71
+ };
72
+ }
73
+ if (/短一点|精简|短句|更直接|删掉废话/.test(normalized)) {
74
+ return {
75
+ section: "Content Edit Preferences",
76
+ learning: "用户偏好更短句、更直接的表达,减少铺陈和废话",
77
+ };
78
+ }
79
+ if (/emoji|表情/.test(normalized)) {
80
+ return {
81
+ section: "Writing Preferences",
82
+ learning: "用户对 emoji 使用有明确偏好,后续需要按平台控制频率和位置",
83
+ };
84
+ }
85
+ if (/标题|hook|开头|前3秒/.test(normalized)) {
86
+ return {
87
+ section: "Content Edit Preferences",
88
+ learning: "用户对标题和开头钩子更敏感,后续应优先优化停留感和首屏表达",
89
+ };
90
+ }
91
+ if (/爆|点赞|收藏|转化|播放|点击率/.test(normalized)) {
92
+ return {
93
+ section: "Performance Insights",
94
+ learning: normalized,
95
+ };
96
+ }
97
+
98
+ return {
99
+ section: "Content Edit Preferences",
100
+ learning: normalized,
101
+ };
102
+ }
103
+
104
+ function inferFromEdit(originalText: string, modifiedText: string): { section: string; learning: string } {
105
+ const originalLength = originalText.length;
106
+ const modifiedLength = modifiedText.length;
107
+ if (modifiedLength < originalLength * 0.8) {
108
+ return {
109
+ section: "Content Edit Preferences",
110
+ learning: "用户修改时明显压缩篇幅,偏好更短、更狠、更直接的表达",
111
+ };
112
+ }
113
+
114
+ if (/口语|你|我|问题来了|说白了/.test(modifiedText) && !/口语|你|我|问题来了|说白了/.test(originalText)) {
115
+ return {
116
+ section: "Writing Preferences",
117
+ learning: "用户会主动把稿子改得更口语、更像当面说话",
118
+ };
119
+ }
120
+
121
+ return {
122
+ section: "Content Edit Preferences",
123
+ learning: "用户对草稿做了实质修改,后续应优先参考最近一次改稿方向",
124
+ };
125
+ }
126
+
127
+ function buildLearning(options: DistillMemoryOptions): { section: string; learning: string } {
128
+ if (options.signalType === "edit" && options.originalText && options.modifiedText) {
129
+ return inferFromEdit(options.originalText, options.modifiedText);
130
+ }
131
+
132
+ if (options.feedback) {
133
+ const inferred = inferFromFeedback(options.feedback);
134
+ if (inferred) return inferred;
135
+ }
136
+
137
+ switch (options.signalType) {
138
+ case "approval":
139
+ return {
140
+ section: "Performance Insights",
141
+ learning: `用户确认了${options.platform ? `${options.platform} ` : ""}方向,当前表达方式可继续复用`,
142
+ };
143
+ case "rejection":
144
+ return {
145
+ section: "Content Edit Preferences",
146
+ learning: `用户拒绝了当前${options.platform ? `${options.platform} ` : ""}稿件方向,后续需要先重做角度而不是只润色句子`,
147
+ };
148
+ case "performance":
149
+ return {
150
+ section: "Performance Insights",
151
+ learning: options.feedback || "记录了一条新的内容表现反馈",
152
+ };
153
+ default:
154
+ return {
155
+ section: "Content Edit Preferences",
156
+ learning: options.feedback || "记录了一条新的用户反馈",
157
+ };
158
+ }
159
+ }
160
+
161
+ function appendLearningToSection(memoryContent: string, section: string, learning: string): string {
162
+ const marker = `## ${section}`;
163
+ const entry = `- [${today()}] ${learning}`;
164
+ if (!memoryContent.includes(marker)) {
165
+ return `${memoryContent.trim()}\n\n${marker}\n\n${entry}\n`;
166
+ }
167
+
168
+ const existingEntryPattern = new RegExp(`^- \\[\\d{4}-\\d{2}-\\d{2}\\] ${learning.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m");
169
+ if (existingEntryPattern.test(memoryContent)) {
170
+ return memoryContent;
171
+ }
172
+
173
+ const start = memoryContent.indexOf(marker) + marker.length;
174
+ const nextSectionIndex = memoryContent.indexOf("\n## ", start);
175
+ if (nextSectionIndex === -1) {
176
+ return `${memoryContent.trimEnd()}\n\n${entry}\n`;
177
+ }
178
+
179
+ return `${memoryContent.slice(0, nextSectionIndex).trimEnd()}\n\n${entry}\n${memoryContent.slice(nextSectionIndex)}`;
180
+ }
181
+
182
+ export async function distillMemory(options: DistillMemoryOptions): Promise<DistillMemoryResult> {
183
+ const { memoryPath, archiveDir } = await ensureMemoryFiles(options.dataDir);
184
+ const raw = await fs.readFile(memoryPath, "utf-8");
185
+ const { section, learning } = buildLearning(options);
186
+ const nextMemory = appendLearningToSection(raw, section, learning);
187
+ await fs.writeFile(memoryPath, nextMemory, "utf-8");
188
+
189
+ const archivePath = path.join(archiveDir, `${today()}.jsonl`);
190
+ const archiveEntry = {
191
+ timestamp: new Date().toISOString(),
192
+ signalType: options.signalType,
193
+ feedback: options.feedback || null,
194
+ contentTitle: options.contentTitle || null,
195
+ platform: options.platform || null,
196
+ section,
197
+ learning,
198
+ };
199
+ await fs.appendFile(archivePath, `${JSON.stringify(archiveEntry, null, 0)}\n`, "utf-8");
200
+
201
+ return {
202
+ ok: true,
203
+ section,
204
+ learning,
205
+ memoryPath,
206
+ archivePath,
207
+ };
208
+ }
@@ -0,0 +1,169 @@
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 yaml from "js-yaml";
6
+ import { migrateLegacyData } from "./legacy-migrate.js";
7
+ import {
8
+ stagePath,
9
+ listTopics,
10
+ type ProjectMeta,
11
+ } from "../../storage/pipeline-store.js";
12
+
13
+ let testDir: string;
14
+
15
+ beforeEach(async () => {
16
+ testDir = await fs.mkdtemp(
17
+ path.join(os.tmpdir(), "autocrew-migrate-test-"),
18
+ );
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(testDir, { recursive: true, force: true });
23
+ });
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────────────────
26
+
27
+ async function writeLegacyTopic(filename: string, data: Record<string, unknown>): Promise<void> {
28
+ const dir = path.join(testDir, "topics");
29
+ await fs.mkdir(dir, { recursive: true });
30
+ await fs.writeFile(path.join(dir, filename), JSON.stringify(data), "utf-8");
31
+ }
32
+
33
+ async function writeLegacyContent(
34
+ id: string,
35
+ meta: Record<string, unknown>,
36
+ draftContent?: string,
37
+ ): Promise<void> {
38
+ const dir = path.join(testDir, "contents", `content-${id}`);
39
+ await fs.mkdir(dir, { recursive: true });
40
+ await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta), "utf-8");
41
+ if (draftContent) {
42
+ await fs.writeFile(path.join(dir, "draft.md"), draftContent, "utf-8");
43
+ }
44
+ }
45
+
46
+ // ─── Tests ──────────────────────────────────────────────────────────────────
47
+
48
+ describe("Legacy Migration", () => {
49
+ it("migrates legacy topic JSON to markdown", async () => {
50
+ await writeLegacyTopic("topic-1.json", {
51
+ title: "AI写作工具评测",
52
+ domain: "ai-content",
53
+ score: { heat: 80, differentiation: 70, audienceFit: 90, overall: 80 },
54
+ formats: ["video"],
55
+ suggested_platforms: ["小红书"],
56
+ created_at: "2024-01-15T00:00:00.000Z",
57
+ intel_refs: [],
58
+ angles: ["横向对比"],
59
+ audience_resonance: "强烈兴趣",
60
+ references: [],
61
+ });
62
+
63
+ const result = await migrateLegacyData(testDir);
64
+
65
+ expect(result.topicsMigrated).toBe(1);
66
+ expect(result.errors).toHaveLength(0);
67
+
68
+ const topics = await listTopics(undefined, testDir);
69
+ expect(topics.length).toBe(1);
70
+ expect(topics[0].title).toBe("AI写作工具评测");
71
+ expect(topics[0].score.overall).toBe(80);
72
+ expect(topics[0].angles).toEqual(["横向对比"]);
73
+ });
74
+
75
+ it("migrates legacy content to correct pipeline stage", async () => {
76
+ await writeLegacyContent("001", {
77
+ title: "测试内容",
78
+ domain: "tech",
79
+ status: "drafting",
80
+ format: "article",
81
+ created_at: "2024-02-01T00:00:00.000Z",
82
+ }, "# 测试内容\n\n草稿正文");
83
+
84
+ await writeLegacyContent("002", {
85
+ title: "已发布内容",
86
+ domain: "tech",
87
+ status: "published",
88
+ format: "video",
89
+ created_at: "2024-01-01T00:00:00.000Z",
90
+ }, "# 已发布\n\n正文");
91
+
92
+ await writeLegacyContent("003", {
93
+ title: "审核通过",
94
+ domain: "lifestyle",
95
+ status: "approved",
96
+ format: "article",
97
+ });
98
+
99
+ const result = await migrateLegacyData(testDir);
100
+
101
+ expect(result.contentsMigrated).toBe(3);
102
+ expect(result.errors).toHaveLength(0);
103
+
104
+ // Check drafting stage
105
+ const draftingDir = stagePath("drafting", testDir);
106
+ const draftingEntries = await fs.readdir(draftingDir);
107
+ expect(draftingEntries.some((e) => e.includes("测试内容"))).toBe(true);
108
+
109
+ // Check published stage
110
+ const publishedDir = stagePath("published", testDir);
111
+ const publishedEntries = await fs.readdir(publishedDir);
112
+ expect(publishedEntries.some((e) => e.includes("已发布内容"))).toBe(true);
113
+
114
+ // Check production stage
115
+ const productionDir = stagePath("production", testDir);
116
+ const productionEntries = await fs.readdir(productionDir);
117
+ expect(productionEntries.some((e) => e.includes("审核通过"))).toBe(true);
118
+
119
+ // Verify meta.yaml was created with correct data
120
+ const draftProject = draftingEntries.find((e) => e.includes("测试内容"))!;
121
+ const metaContent = await fs.readFile(
122
+ path.join(draftingDir, draftProject, "meta.yaml"),
123
+ "utf-8",
124
+ );
125
+ const meta = yaml.load(metaContent) as ProjectMeta;
126
+ expect(meta.title).toBe("测试内容");
127
+ expect(meta.domain).toBe("tech");
128
+ expect(meta.history[0].stage).toBe("drafting");
129
+
130
+ // Verify draft-v1.md was created from draft.md
131
+ const draftV1 = await fs.readFile(
132
+ path.join(draftingDir, draftProject, "draft-v1.md"),
133
+ "utf-8",
134
+ );
135
+ expect(draftV1).toBe("# 测试内容\n\n草稿正文");
136
+ });
137
+
138
+ it("is idempotent — running twice does not duplicate", async () => {
139
+ await writeLegacyTopic("topic-1.json", {
140
+ title: "重复测试",
141
+ domain: "test",
142
+ score: { heat: 50, differentiation: 50, audienceFit: 50, overall: 50 },
143
+ formats: [],
144
+ created_at: "2024-01-01T00:00:00.000Z",
145
+ angles: [],
146
+ audience_resonance: "",
147
+ references: [],
148
+ });
149
+
150
+ await writeLegacyContent("100", {
151
+ title: "重复内容",
152
+ domain: "test",
153
+ status: "drafting",
154
+ format: "article",
155
+ });
156
+
157
+ const first = await migrateLegacyData(testDir);
158
+ expect(first.topicsMigrated).toBe(1);
159
+ expect(first.contentsMigrated).toBe(1);
160
+
161
+ const second = await migrateLegacyData(testDir);
162
+ expect(second.topicsMigrated).toBe(0);
163
+ expect(second.contentsMigrated).toBe(0);
164
+
165
+ // Still only one of each
166
+ const topics = await listTopics(undefined, testDir);
167
+ expect(topics.length).toBe(1);
168
+ });
169
+ });