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,698 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import matter from "gray-matter";
4
+ import yaml from "js-yaml";
5
+
6
+ // ─── Constants ───────────────────────────────────────────────────────────────
7
+
8
+ export const PIPELINE_STAGES = [
9
+ "intel",
10
+ "topics",
11
+ "drafting",
12
+ "production",
13
+ "published",
14
+ "trash",
15
+ ] as const;
16
+
17
+ export type PipelineStage = (typeof PIPELINE_STAGES)[number];
18
+
19
+ const DEFAULT_DATA_DIR = path.join(
20
+ process.env.HOME ?? ".",
21
+ ".autocrew",
22
+ "data",
23
+ );
24
+
25
+ // ─── Types ───────────────────────────────────────────────────────────────────
26
+
27
+ export interface IntelItem {
28
+ title: string;
29
+ domain: string;
30
+ source: "web_search" | "rss" | "competitor" | "trend" | "manual";
31
+ sourceUrl?: string;
32
+ collectedAt: string;
33
+ relevance: number;
34
+ tags: string[];
35
+ expiresAfter: number; // days
36
+ summary: string;
37
+ keyPoints: string[];
38
+ topicPotential: string;
39
+ }
40
+
41
+ export interface TopicScore {
42
+ heat: number;
43
+ differentiation: number;
44
+ audienceFit: number;
45
+ overall: number;
46
+ }
47
+
48
+ export interface TopicCandidate {
49
+ title: string;
50
+ domain: string;
51
+ score: TopicScore;
52
+ formats: string[];
53
+ suggestedPlatforms: string[];
54
+ createdAt: string;
55
+ intelRefs: string[];
56
+ angles: string[];
57
+ audienceResonance: string;
58
+ references: string[];
59
+ }
60
+
61
+ export interface DraftVersion {
62
+ file: string;
63
+ createdAt: string;
64
+ note: string;
65
+ }
66
+
67
+ export interface PlatformStatus {
68
+ format: string;
69
+ status: string;
70
+ }
71
+
72
+ export interface PerformanceData {
73
+ views?: number;
74
+ completionRate?: number;
75
+ likes?: number;
76
+ saves?: number;
77
+ comments?: number;
78
+ shares?: number;
79
+ topComments?: string[];
80
+ collectedAt?: string;
81
+ }
82
+
83
+ export interface PerformanceLearning {
84
+ contentId: string;
85
+ rating: "viral" | "on_target" | "below_expectation";
86
+ coreAttribution: "strong_title" | "good_hook" | "right_topic" | "timing" | "luck";
87
+ hypothesisResult: "confirmed" | "rejected" | "inconclusive";
88
+ learning: string;
89
+ createdAt: string;
90
+ }
91
+
92
+ export interface StageEntry {
93
+ stage: PipelineStage;
94
+ entered: string;
95
+ }
96
+
97
+ export interface ProjectMeta {
98
+ title: string;
99
+ domain: string;
100
+ format: string;
101
+ createdAt: string;
102
+ sourceTopic: string;
103
+ intelRefs: string[];
104
+ versions: DraftVersion[];
105
+ current: string;
106
+ history: StageEntry[];
107
+ platforms: PlatformStatus[];
108
+ hypothesis?: string;
109
+ experimentType?: "title_test" | "hook_test" | "format_test" | "angle_test";
110
+ controlRef?: string;
111
+ hypothesisResult?: "confirmed" | "rejected" | "inconclusive";
112
+ performanceData?: PerformanceData;
113
+ performanceLearnings?: PerformanceLearning[];
114
+ contentPillar?: string;
115
+ commentTriggers?: Array<{ type: "controversy" | "unanswered_question" | "quote_hook"; position: string }>;
116
+ }
117
+
118
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
119
+
120
+ export function slugify(text: string): string {
121
+ return text
122
+ .toLowerCase()
123
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
124
+ .replace(/^-+|-+$/g, "")
125
+ .slice(0, 60);
126
+ }
127
+
128
+ // ─── Directory Init ─────────────────────────────────────────────────────────
129
+
130
+ export function pipelinePath(dataDir?: string): string {
131
+ return path.join(dataDir ?? DEFAULT_DATA_DIR, "pipeline");
132
+ }
133
+
134
+ export function stagePath(stage: PipelineStage, dataDir?: string): string {
135
+ return path.join(pipelinePath(dataDir), stage);
136
+ }
137
+
138
+ export async function initPipeline(dataDir?: string): Promise<void> {
139
+ for (const stage of PIPELINE_STAGES) {
140
+ await fs.mkdir(stagePath(stage, dataDir), { recursive: true });
141
+ }
142
+ // Intel sub-directories
143
+ await fs.mkdir(path.join(stagePath("intel", dataDir), "_sources"), {
144
+ recursive: true,
145
+ });
146
+ await fs.mkdir(path.join(stagePath("intel", dataDir), "_archive"), {
147
+ recursive: true,
148
+ });
149
+ await fs.mkdir(path.join(stagePath("intel", dataDir), "_teardowns"), {
150
+ recursive: true,
151
+ });
152
+ }
153
+
154
+ // ─── Intel Storage ──────────────────────────────────────────────────────────
155
+
156
+ export function intelToMarkdown(item: IntelItem): string {
157
+ const frontmatter: Record<string, unknown> = {
158
+ title: item.title,
159
+ domain: item.domain,
160
+ source: item.source,
161
+ collected_at: item.collectedAt,
162
+ relevance: item.relevance,
163
+ tags: item.tags,
164
+ expires_after: item.expiresAfter,
165
+ };
166
+ if (item.sourceUrl) frontmatter.source_url = item.sourceUrl;
167
+
168
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd();
169
+
170
+ const body = `# ${item.title}
171
+
172
+ ## 摘要
173
+
174
+ ${item.summary}
175
+
176
+ ## 关键信息
177
+
178
+ ${item.keyPoints.map((p) => `- ${p}`).join("\n")}
179
+
180
+ ## 选题潜力
181
+
182
+ ${item.topicPotential}
183
+ `;
184
+
185
+ return `---\n${yamlStr}\n---\n\n${body}`;
186
+ }
187
+
188
+ export function parseIntelFile(content: string): IntelItem {
189
+ const { data, content: body } = matter(content);
190
+
191
+ // Extract key points from body
192
+ const keyPointsMatch = body.match(/## 关键信息\n\n([\s\S]*?)(?:\n## |$)/);
193
+ const keyPoints = keyPointsMatch
194
+ ? keyPointsMatch[1]
195
+ .trim()
196
+ .split("\n")
197
+ .filter((l) => l.startsWith("- "))
198
+ .map((l) => l.slice(2))
199
+ : [];
200
+
201
+ // Extract summary
202
+ const summaryMatch = body.match(/## 摘要\n\n([\s\S]*?)(?:\n## |$)/);
203
+ const summary = summaryMatch ? summaryMatch[1].trim() : "";
204
+
205
+ // Extract topic potential
206
+ const potentialMatch = body.match(/## 选题潜力\n\n([\s\S]*?)$/);
207
+ const topicPotential = potentialMatch ? potentialMatch[1].trim() : "";
208
+
209
+ return {
210
+ title: data.title,
211
+ domain: data.domain,
212
+ source: data.source,
213
+ sourceUrl: data.source_url,
214
+ collectedAt: data.collected_at,
215
+ relevance: data.relevance,
216
+ tags: data.tags ?? [],
217
+ expiresAfter: data.expires_after,
218
+ summary,
219
+ keyPoints,
220
+ topicPotential,
221
+ };
222
+ }
223
+
224
+ export async function saveIntel(
225
+ item: IntelItem,
226
+ dataDir?: string,
227
+ ): Promise<string> {
228
+ await initPipeline(dataDir);
229
+ const domainDir = path.join(stagePath("intel", dataDir), item.domain);
230
+ await fs.mkdir(domainDir, { recursive: true });
231
+
232
+ const slug = slugify(item.title);
233
+ const datePrefix = item.collectedAt.slice(0, 10);
234
+ const filename = `${datePrefix}-${slug}.md`;
235
+ const filePath = path.join(domainDir, filename);
236
+
237
+ // Dedup: check if a file with the same slug already exists
238
+ try {
239
+ const files = await fs.readdir(domainDir);
240
+ const existing = files.find((f) => f.endsWith(`-${slug}.md`));
241
+ if (existing) {
242
+ // Overwrite existing
243
+ const existingPath = path.join(domainDir, existing);
244
+ await fs.writeFile(existingPath, intelToMarkdown(item), "utf-8");
245
+ return existingPath;
246
+ }
247
+ } catch {
248
+ // directory may not exist yet, that's fine
249
+ }
250
+
251
+ await fs.writeFile(filePath, intelToMarkdown(item), "utf-8");
252
+ return filePath;
253
+ }
254
+
255
+ export async function listIntel(
256
+ domain?: string,
257
+ dataDir?: string,
258
+ ): Promise<IntelItem[]> {
259
+ const intelDir = stagePath("intel", dataDir);
260
+ const items: IntelItem[] = [];
261
+
262
+ let domains: string[];
263
+ try {
264
+ const entries = await fs.readdir(intelDir, { withFileTypes: true });
265
+ domains = entries
266
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_"))
267
+ .map((e) => e.name);
268
+ } catch {
269
+ return [];
270
+ }
271
+
272
+ if (domain) {
273
+ domains = domains.filter((d) => d === domain);
274
+ }
275
+
276
+ for (const d of domains) {
277
+ const domainDir = path.join(intelDir, d);
278
+ const files = await fs.readdir(domainDir);
279
+ for (const f of files) {
280
+ if (!f.endsWith(".md")) continue;
281
+ const content = await fs.readFile(path.join(domainDir, f), "utf-8");
282
+ items.push(parseIntelFile(content));
283
+ }
284
+ }
285
+
286
+ items.sort(
287
+ (a, b) =>
288
+ new Date(b.collectedAt).getTime() - new Date(a.collectedAt).getTime(),
289
+ );
290
+ return items;
291
+ }
292
+
293
+ export async function archiveExpiredIntel(
294
+ dataDir?: string,
295
+ ): Promise<{ archived: number }> {
296
+ const intelDir = stagePath("intel", dataDir);
297
+ const archiveDir = path.join(intelDir, "_archive");
298
+ await fs.mkdir(archiveDir, { recursive: true });
299
+
300
+ let archived = 0;
301
+ let domains: string[];
302
+ try {
303
+ const entries = await fs.readdir(intelDir, { withFileTypes: true });
304
+ domains = entries
305
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_"))
306
+ .map((e) => e.name);
307
+ } catch {
308
+ return { archived: 0 };
309
+ }
310
+
311
+ const now = Date.now();
312
+
313
+ for (const d of domains) {
314
+ const domainDir = path.join(intelDir, d);
315
+ const files = await fs.readdir(domainDir);
316
+ for (const f of files) {
317
+ if (!f.endsWith(".md")) continue;
318
+ const filePath = path.join(domainDir, f);
319
+ const content = await fs.readFile(filePath, "utf-8");
320
+ const item = parseIntelFile(content);
321
+ const collectedMs = new Date(item.collectedAt).getTime();
322
+ const expiresMs = item.expiresAfter * 24 * 60 * 60 * 1000;
323
+ if (now - collectedMs > expiresMs) {
324
+ await fs.rename(filePath, path.join(archiveDir, f));
325
+ archived++;
326
+ }
327
+ }
328
+ }
329
+
330
+ return { archived };
331
+ }
332
+
333
+ // ─── Topic Pool ─────────────────────────────────────────────────────────────
334
+
335
+ export function topicToMarkdown(topic: TopicCandidate): string {
336
+ const frontmatter: Record<string, unknown> = {
337
+ title: topic.title,
338
+ domain: topic.domain,
339
+ score_heat: topic.score.heat,
340
+ score_differentiation: topic.score.differentiation,
341
+ score_audience_fit: topic.score.audienceFit,
342
+ score_overall: topic.score.overall,
343
+ formats: topic.formats,
344
+ suggested_platforms: topic.suggestedPlatforms,
345
+ created_at: topic.createdAt,
346
+ intel_refs: topic.intelRefs,
347
+ references: topic.references,
348
+ };
349
+
350
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd();
351
+
352
+ const body = `# ${topic.title}
353
+
354
+ ## 切入角度
355
+
356
+ ${topic.angles.map((a) => `- ${a}`).join("\n")}
357
+
358
+ ## 目标受众共鸣点
359
+
360
+ ${topic.audienceResonance}
361
+
362
+ ## 参考素材
363
+
364
+ ${topic.references.map((r) => `- ${r}`).join("\n")}
365
+ `;
366
+
367
+ return `---\n${yamlStr}\n---\n\n${body}`;
368
+ }
369
+
370
+ export function parseTopicFile(content: string): TopicCandidate {
371
+ const { data, content: body } = matter(content);
372
+
373
+ const anglesMatch = body.match(/## 切入角度\n\n([\s\S]*?)(?:\n## |$)/);
374
+ const angles = anglesMatch
375
+ ? anglesMatch[1]
376
+ .trim()
377
+ .split("\n")
378
+ .filter((l) => l.startsWith("- "))
379
+ .map((l) => l.slice(2))
380
+ : [];
381
+
382
+ const resonanceMatch = body.match(
383
+ /## 目标受众共鸣点\n\n([\s\S]*?)(?:\n## |$)/,
384
+ );
385
+ const audienceResonance = resonanceMatch ? resonanceMatch[1].trim() : "";
386
+
387
+ return {
388
+ title: data.title,
389
+ domain: data.domain,
390
+ score: {
391
+ heat: data.score_heat,
392
+ differentiation: data.score_differentiation,
393
+ audienceFit: data.score_audience_fit,
394
+ overall: data.score_overall,
395
+ },
396
+ formats: data.formats ?? [],
397
+ suggestedPlatforms: data.suggested_platforms ?? [],
398
+ createdAt: data.created_at,
399
+ intelRefs: data.intel_refs ?? [],
400
+ angles,
401
+ audienceResonance,
402
+ references: data.references ?? [],
403
+ };
404
+ }
405
+
406
+ export async function saveTopic(
407
+ topic: TopicCandidate,
408
+ dataDir?: string,
409
+ ): Promise<string> {
410
+ await initPipeline(dataDir);
411
+ const topicsDir = stagePath("topics", dataDir);
412
+ const filename = `${slugify(topic.domain)}-${slugify(topic.title)}.md`;
413
+ const filePath = path.join(topicsDir, filename);
414
+ await fs.writeFile(filePath, topicToMarkdown(topic), "utf-8");
415
+ return filePath;
416
+ }
417
+
418
+ export async function listTopics(
419
+ domain?: string,
420
+ dataDir?: string,
421
+ ): Promise<TopicCandidate[]> {
422
+ const topicsDir = stagePath("topics", dataDir);
423
+ const items: TopicCandidate[] = [];
424
+
425
+ let files: string[];
426
+ try {
427
+ files = await fs.readdir(topicsDir);
428
+ } catch {
429
+ return [];
430
+ }
431
+
432
+ for (const f of files) {
433
+ if (!f.endsWith(".md")) continue;
434
+ const content = await fs.readFile(path.join(topicsDir, f), "utf-8");
435
+ const topic = parseTopicFile(content);
436
+ if (domain && topic.domain !== domain) continue;
437
+ items.push(topic);
438
+ }
439
+
440
+ items.sort((a, b) => b.score.overall - a.score.overall);
441
+ return items;
442
+ }
443
+
444
+ export async function decayTopicScores(
445
+ dataDir?: string,
446
+ ): Promise<{ decayed: number; trashed: number }> {
447
+ const topicsDir = stagePath("topics", dataDir);
448
+ const trashDir = stagePath("trash", dataDir);
449
+ await fs.mkdir(trashDir, { recursive: true });
450
+
451
+ let decayed = 0;
452
+ let trashed = 0;
453
+
454
+ let files: string[];
455
+ try {
456
+ files = await fs.readdir(topicsDir);
457
+ } catch {
458
+ return { decayed: 0, trashed: 0 };
459
+ }
460
+
461
+ const now = Date.now();
462
+ const DAY_MS = 24 * 60 * 60 * 1000;
463
+ const DECAY_AFTER_DAYS = 14;
464
+ const DECAY_PER_DAY = 2;
465
+ const TRASH_THRESHOLD = 20;
466
+
467
+ for (const f of files) {
468
+ if (!f.endsWith(".md")) continue;
469
+ const filePath = path.join(topicsDir, f);
470
+ const content = await fs.readFile(filePath, "utf-8");
471
+ const topic = parseTopicFile(content);
472
+ const createdMs = new Date(topic.createdAt).getTime();
473
+ const ageDays = (now - createdMs) / DAY_MS;
474
+
475
+ if (ageDays <= DECAY_AFTER_DAYS) continue;
476
+
477
+ const decayDays = ageDays - DECAY_AFTER_DAYS;
478
+ const decayAmount = Math.floor(decayDays) * DECAY_PER_DAY;
479
+
480
+ topic.score.overall = Math.max(0, topic.score.overall - decayAmount);
481
+ decayed++;
482
+
483
+ if (topic.score.overall < TRASH_THRESHOLD) {
484
+ await fs.rename(filePath, path.join(trashDir, f));
485
+ trashed++;
486
+ } else {
487
+ await fs.writeFile(filePath, topicToMarkdown(topic), "utf-8");
488
+ }
489
+ }
490
+
491
+ return { decayed, trashed };
492
+ }
493
+
494
+ // ─── Project Lifecycle ──────────────────────────────────────────────────────
495
+
496
+ export async function readMeta(dir: string): Promise<ProjectMeta> {
497
+ const content = await fs.readFile(path.join(dir, "meta.yaml"), "utf-8");
498
+ return yaml.load(content) as ProjectMeta;
499
+ }
500
+
501
+ export async function writeMeta(dir: string, meta: ProjectMeta): Promise<void> {
502
+ await fs.writeFile(
503
+ path.join(dir, "meta.yaml"),
504
+ yaml.dump(meta, { lineWidth: -1 }),
505
+ "utf-8",
506
+ );
507
+ }
508
+
509
+ export async function findProject(
510
+ name: string,
511
+ dataDir?: string,
512
+ ): Promise<{ dir: string; stage: PipelineStage } | null> {
513
+ for (const stage of PIPELINE_STAGES) {
514
+ if (stage === "intel" || stage === "topics") continue;
515
+ const dir = path.join(stagePath(stage, dataDir), name);
516
+ try {
517
+ const stat = await fs.stat(dir);
518
+ if (stat.isDirectory()) return { dir, stage };
519
+ } catch {
520
+ // not here
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+
526
+ export async function startProject(
527
+ topicSlug: string,
528
+ dataDir?: string,
529
+ ): Promise<string> {
530
+ await initPipeline(dataDir);
531
+ const topicsDir = stagePath("topics", dataDir);
532
+
533
+ // Find the topic file
534
+ const files = await fs.readdir(topicsDir);
535
+ const topicFile = files.find(
536
+ (f) => f.endsWith(".md") && f.includes(topicSlug),
537
+ );
538
+ if (!topicFile) throw new Error(`Topic not found: ${topicSlug}`);
539
+
540
+ const content = await fs.readFile(
541
+ path.join(topicsDir, topicFile),
542
+ "utf-8",
543
+ );
544
+ const topic = parseTopicFile(content);
545
+ const projectName = slugify(topic.title);
546
+
547
+ const projectDir = path.join(stagePath("drafting", dataDir), projectName);
548
+ await fs.mkdir(projectDir, { recursive: true });
549
+ await fs.mkdir(path.join(projectDir, "references"), { recursive: true });
550
+
551
+ const now = new Date().toISOString();
552
+
553
+ const meta: ProjectMeta = {
554
+ title: topic.title,
555
+ domain: topic.domain,
556
+ format: topic.formats[0] ?? "article",
557
+ createdAt: now,
558
+ sourceTopic: topicFile,
559
+ intelRefs: topic.intelRefs,
560
+ versions: [{ file: "draft-v1.md", createdAt: now, note: "initial draft" }],
561
+ current: "draft-v1.md",
562
+ history: [{ stage: "drafting", entered: now }],
563
+ platforms: [],
564
+ };
565
+
566
+ await writeMeta(projectDir, meta);
567
+ await fs.writeFile(
568
+ path.join(projectDir, "draft-v1.md"),
569
+ `# ${topic.title}\n\n`,
570
+ "utf-8",
571
+ );
572
+ await fs.writeFile(
573
+ path.join(projectDir, "draft.md"),
574
+ `# ${topic.title}\n\n`,
575
+ "utf-8",
576
+ );
577
+
578
+ // Remove topic file
579
+ await fs.unlink(path.join(topicsDir, topicFile));
580
+
581
+ return projectDir;
582
+ }
583
+
584
+ const STAGE_ORDER: PipelineStage[] = ["drafting", "production", "published"];
585
+
586
+ export async function advanceProject(
587
+ name: string,
588
+ dataDir?: string,
589
+ ): Promise<string> {
590
+ const found = await findProject(name, dataDir);
591
+ if (!found) throw new Error(`Project not found: ${name}`);
592
+
593
+ const currentIdx = STAGE_ORDER.indexOf(found.stage);
594
+ if (currentIdx === -1 || currentIdx >= STAGE_ORDER.length - 1) {
595
+ throw new Error(`Cannot advance from stage: ${found.stage}`);
596
+ }
597
+
598
+ const nextStage = STAGE_ORDER[currentIdx + 1];
599
+ const newDir = path.join(stagePath(nextStage, dataDir), name);
600
+
601
+ await fs.rename(found.dir, newDir);
602
+
603
+ const meta = await readMeta(newDir);
604
+ meta.history.push({ stage: nextStage, entered: new Date().toISOString() });
605
+ await writeMeta(newDir, meta);
606
+
607
+ return newDir;
608
+ }
609
+
610
+ export async function addDraftVersion(
611
+ name: string,
612
+ content: string,
613
+ note: string,
614
+ dataDir?: string,
615
+ ): Promise<string> {
616
+ const found = await findProject(name, dataDir);
617
+ if (!found) throw new Error(`Project not found: ${name}`);
618
+
619
+ const meta = await readMeta(found.dir);
620
+ const versionNum = meta.versions.length + 1;
621
+ const filename = `draft-v${versionNum}.md`;
622
+
623
+ await fs.writeFile(path.join(found.dir, filename), content, "utf-8");
624
+ // Update draft.md to latest content
625
+ await fs.writeFile(path.join(found.dir, "draft.md"), content, "utf-8");
626
+
627
+ meta.versions.push({
628
+ file: filename,
629
+ createdAt: new Date().toISOString(),
630
+ note,
631
+ });
632
+ meta.current = filename;
633
+ await writeMeta(found.dir, meta);
634
+
635
+ return path.join(found.dir, filename);
636
+ }
637
+
638
+ export async function trashProject(
639
+ name: string,
640
+ dataDir?: string,
641
+ ): Promise<void> {
642
+ const found = await findProject(name, dataDir);
643
+ if (!found) throw new Error(`Project not found: ${name}`);
644
+
645
+ const trashDir = path.join(stagePath("trash", dataDir), name);
646
+ await fs.rename(found.dir, trashDir);
647
+
648
+ const meta = await readMeta(trashDir);
649
+ meta.history.push({ stage: "trash", entered: new Date().toISOString() });
650
+ await writeMeta(trashDir, meta);
651
+ }
652
+
653
+ export async function restoreProject(
654
+ name: string,
655
+ dataDir?: string,
656
+ ): Promise<string> {
657
+ const trashDir = path.join(stagePath("trash", dataDir), name);
658
+ const meta = await readMeta(trashDir);
659
+
660
+ // Find the previous stage (the one before trash)
661
+ const previousEntry = meta.history
662
+ .filter((h) => h.stage !== "trash")
663
+ .at(-1);
664
+ const restoreStage = previousEntry?.stage ?? "drafting";
665
+
666
+ const newDir = path.join(stagePath(restoreStage, dataDir), name);
667
+ await fs.rename(trashDir, newDir);
668
+
669
+ meta.history.push({
670
+ stage: restoreStage,
671
+ entered: new Date().toISOString(),
672
+ });
673
+ await writeMeta(newDir, meta);
674
+
675
+ return newDir;
676
+ }
677
+
678
+ export async function getProjectMeta(
679
+ name: string,
680
+ dataDir?: string,
681
+ ): Promise<ProjectMeta | null> {
682
+ const found = await findProject(name, dataDir);
683
+ if (!found) return null;
684
+ return readMeta(found.dir);
685
+ }
686
+
687
+ export async function listProjects(
688
+ stage: PipelineStage,
689
+ dataDir?: string,
690
+ ): Promise<string[]> {
691
+ const dir = stagePath(stage, dataDir);
692
+ try {
693
+ const entries = await fs.readdir(dir, { withFileTypes: true });
694
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
695
+ } catch {
696
+ return [];
697
+ }
698
+ }