autocrew 0.1.0 → 0.2.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/package.json +1 -1
- package/skills/content-review/SKILL.md +56 -6
- package/skills/feature-triage/SKILL.md +335 -0
- package/skills/knowledge-sync/SKILL.md +137 -0
- package/skills/onboarding/SKILL.md +6 -3
- package/skills/setup/SKILL.md +8 -3
- package/skills/spawn-writer/SKILL.md +11 -1
- package/skills/teardown/SKILL.md +254 -44
- package/skills/write-script/SKILL.md +154 -1
- package/src/modules/intel/integration.test.ts +9 -7
- package/src/modules/profile/creator-profile.test.ts +51 -0
- package/src/modules/profile/creator-profile.ts +36 -6
- package/src/modules/wiki/wiki.test.ts +213 -0
- package/src/storage/pipeline-store.test.ts +118 -11
- package/src/storage/pipeline-store.ts +175 -20
- package/src/tools/content-save.ts +8 -10
- package/src/tools/intel.test.ts +61 -0
- package/src/tools/intel.ts +111 -3
- package/src/tools/registry.ts +2 -1
|
@@ -63,6 +63,21 @@ export interface ContentPillar {
|
|
|
63
63
|
exampleAngles: string[];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export interface VideoCrawlerConfig {
|
|
67
|
+
type: "mediacrawl" | "playwright" | "manual";
|
|
68
|
+
/** Command to run for mediacrawl mode, e.g. "python3 /path/to/main.py" */
|
|
69
|
+
command?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OmniConfig {
|
|
73
|
+
/** API base URL, default "https://api.xiaomimimo.com/v1" */
|
|
74
|
+
baseUrl: string;
|
|
75
|
+
/** Model ID, default "mimo-v2-omni" */
|
|
76
|
+
model: string;
|
|
77
|
+
/** API key */
|
|
78
|
+
apiKey: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
export interface CreatorProfile {
|
|
67
82
|
/** User's content industry/niche */
|
|
68
83
|
industry: string;
|
|
@@ -86,6 +101,10 @@ export interface CreatorProfile {
|
|
|
86
101
|
secondaryPersonas: AudiencePersona[];
|
|
87
102
|
/** Content pillars defining the creator's content strategy */
|
|
88
103
|
contentPillars?: ContentPillar[];
|
|
104
|
+
/** Video crawler configuration for teardown video acquisition */
|
|
105
|
+
videoCrawler?: VideoCrawlerConfig;
|
|
106
|
+
/** Omni model configuration for multimodal video analysis */
|
|
107
|
+
omniConfig?: OmniConfig;
|
|
89
108
|
/** Whether style calibration has been completed */
|
|
90
109
|
styleCalibrated: boolean;
|
|
91
110
|
/** Profile creation timestamp */
|
|
@@ -158,15 +177,26 @@ export async function saveProfile(profile: CreatorProfile, dataDir?: string): Pr
|
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
/**
|
|
161
|
-
* Initialize a new empty profile. No-op if
|
|
180
|
+
* Initialize a new empty profile. No-op if the file already exists on disk.
|
|
162
181
|
* Returns the profile (existing or newly created).
|
|
182
|
+
*
|
|
183
|
+
* IMPORTANT: We check file existence, not parsability. If the file exists but
|
|
184
|
+
* can't be parsed, we return an empty in-memory profile but do NOT overwrite
|
|
185
|
+
* the file — protecting user data from being clobbered by a re-init.
|
|
163
186
|
*/
|
|
164
187
|
export async function initProfile(dataDir?: string): Promise<CreatorProfile> {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
188
|
+
const filePath = path.join(getDataDir(dataDir), PROFILE_FILE);
|
|
189
|
+
try {
|
|
190
|
+
await fs.access(filePath);
|
|
191
|
+
// File exists — try to load it, but never overwrite
|
|
192
|
+
const existing = await loadProfile(dataDir);
|
|
193
|
+
return existing ?? emptyProfile();
|
|
194
|
+
} catch {
|
|
195
|
+
// File does not exist — safe to create
|
|
196
|
+
const profile = emptyProfile();
|
|
197
|
+
await saveProfile(profile, dataDir);
|
|
198
|
+
return profile;
|
|
199
|
+
}
|
|
170
200
|
}
|
|
171
201
|
|
|
172
202
|
/**
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
initPipeline,
|
|
7
|
+
saveIntel,
|
|
8
|
+
listIntel,
|
|
9
|
+
saveWikiPage,
|
|
10
|
+
getWikiPage,
|
|
11
|
+
listWikiPages,
|
|
12
|
+
regenerateWikiIndex,
|
|
13
|
+
appendWikiLog,
|
|
14
|
+
stagePath,
|
|
15
|
+
type IntelItem,
|
|
16
|
+
type WikiPage,
|
|
17
|
+
} from "../../storage/pipeline-store.js";
|
|
18
|
+
|
|
19
|
+
let testDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-wiki-test-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("Wiki Integration", () => {
|
|
30
|
+
it("full lifecycle: save intel → create wiki page → regenerate index → log", async () => {
|
|
31
|
+
await initPipeline(testDir);
|
|
32
|
+
|
|
33
|
+
// 1. Save two related intel items about the same entity
|
|
34
|
+
const intel1: IntelItem = {
|
|
35
|
+
title: "Cursor reaches 2.6B valuation",
|
|
36
|
+
domain: "ai-tools",
|
|
37
|
+
source: "web_search",
|
|
38
|
+
collectedAt: new Date().toISOString(),
|
|
39
|
+
relevance: 85,
|
|
40
|
+
tags: ["cursor", "funding"],
|
|
41
|
+
expiresAfter: 90,
|
|
42
|
+
summary: "Anysphere's Cursor editor valued at $2.6B",
|
|
43
|
+
keyPoints: ["VS Code fork", "$2.6B valuation", "AI-first editor"],
|
|
44
|
+
topicPotential: "AI tool funding trends",
|
|
45
|
+
};
|
|
46
|
+
const intel2: IntelItem = {
|
|
47
|
+
title: "Cursor Agent Mode launches",
|
|
48
|
+
domain: "ai-tools",
|
|
49
|
+
source: "web_search",
|
|
50
|
+
collectedAt: new Date().toISOString(),
|
|
51
|
+
relevance: 80,
|
|
52
|
+
tags: ["cursor", "agent"],
|
|
53
|
+
expiresAfter: 90,
|
|
54
|
+
summary: "Cursor launches agent mode for autonomous coding",
|
|
55
|
+
keyPoints: ["Agent mode", "Autonomous coding", "Background tasks"],
|
|
56
|
+
topicPotential: "AI coding evolution",
|
|
57
|
+
};
|
|
58
|
+
await saveIntel(intel1, testDir);
|
|
59
|
+
await saveIntel(intel2, testDir);
|
|
60
|
+
|
|
61
|
+
// 2. Create a wiki page synthesizing both
|
|
62
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
63
|
+
const page: WikiPage = {
|
|
64
|
+
type: "entity",
|
|
65
|
+
title: "Cursor",
|
|
66
|
+
aliases: ["Cursor AI", "Cursor Editor"],
|
|
67
|
+
related: [],
|
|
68
|
+
sources: [
|
|
69
|
+
`ai-tools/${today}-cursor-reaches-2-6b-valuation.md`,
|
|
70
|
+
`ai-tools/${today}-cursor-agent-mode-launches.md`,
|
|
71
|
+
],
|
|
72
|
+
created: today,
|
|
73
|
+
updated: today,
|
|
74
|
+
body: "# Cursor\n\nAI code editor by Anysphere.\n\n## Key Facts\n- VS Code fork with integrated AI\n- $2.6B valuation (2024)\n- Agent Mode for autonomous coding",
|
|
75
|
+
};
|
|
76
|
+
await saveWikiPage(page, testDir);
|
|
77
|
+
|
|
78
|
+
// 3. Verify page saved correctly
|
|
79
|
+
const loaded = await getWikiPage("cursor", testDir);
|
|
80
|
+
expect(loaded).not.toBeNull();
|
|
81
|
+
expect(loaded!.title).toBe("Cursor");
|
|
82
|
+
expect(loaded!.sources).toHaveLength(2);
|
|
83
|
+
expect(loaded!.aliases).toContain("Cursor AI");
|
|
84
|
+
|
|
85
|
+
// 4. Regenerate index
|
|
86
|
+
await regenerateWikiIndex(testDir);
|
|
87
|
+
const indexContent = await fs.readFile(
|
|
88
|
+
path.join(stagePath("wiki", testDir), "index.md"),
|
|
89
|
+
"utf-8",
|
|
90
|
+
);
|
|
91
|
+
expect(indexContent).toContain("[Cursor]");
|
|
92
|
+
expect(indexContent).toContain("## Entities");
|
|
93
|
+
|
|
94
|
+
// 5. Log the sync
|
|
95
|
+
await appendWikiLog("sync", "Created cursor.md from 2 intel sources", testDir);
|
|
96
|
+
const logContent = await fs.readFile(
|
|
97
|
+
path.join(stagePath("wiki", testDir), "log.md"),
|
|
98
|
+
"utf-8",
|
|
99
|
+
);
|
|
100
|
+
expect(logContent).toContain("sync");
|
|
101
|
+
expect(logContent).toContain("cursor.md");
|
|
102
|
+
|
|
103
|
+
// 6. Verify flat structure — no subdirectories
|
|
104
|
+
const wikiFiles = await fs.readdir(stagePath("wiki", testDir));
|
|
105
|
+
expect(wikiFiles).toContain("cursor.md");
|
|
106
|
+
expect(wikiFiles).toContain("index.md");
|
|
107
|
+
expect(wikiFiles).toContain("log.md");
|
|
108
|
+
for (const f of wikiFiles) {
|
|
109
|
+
const stat = await fs.stat(path.join(stagePath("wiki", testDir), f));
|
|
110
|
+
expect(stat.isFile()).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("multiple pages across types with cross-references", async () => {
|
|
115
|
+
await initPipeline(testDir);
|
|
116
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
117
|
+
|
|
118
|
+
await saveWikiPage({
|
|
119
|
+
type: "entity",
|
|
120
|
+
title: "Cursor",
|
|
121
|
+
aliases: ["Cursor AI"],
|
|
122
|
+
related: ["vibe-coding"],
|
|
123
|
+
sources: [],
|
|
124
|
+
created: today,
|
|
125
|
+
updated: today,
|
|
126
|
+
body: "# Cursor\n\nAI code editor.",
|
|
127
|
+
}, testDir);
|
|
128
|
+
|
|
129
|
+
await saveWikiPage({
|
|
130
|
+
type: "concept",
|
|
131
|
+
title: "Vibe Coding",
|
|
132
|
+
aliases: ["vibe coding"],
|
|
133
|
+
related: ["cursor"],
|
|
134
|
+
sources: [],
|
|
135
|
+
created: today,
|
|
136
|
+
updated: today,
|
|
137
|
+
body: "# Vibe Coding\n\nNatural language programming.",
|
|
138
|
+
}, testDir);
|
|
139
|
+
|
|
140
|
+
const pages = await listWikiPages(testDir);
|
|
141
|
+
expect(pages).toHaveLength(2);
|
|
142
|
+
|
|
143
|
+
await regenerateWikiIndex(testDir);
|
|
144
|
+
const indexContent = await fs.readFile(
|
|
145
|
+
path.join(stagePath("wiki", testDir), "index.md"),
|
|
146
|
+
"utf-8",
|
|
147
|
+
);
|
|
148
|
+
expect(indexContent).toContain("## Entities");
|
|
149
|
+
expect(indexContent).toContain("## Concepts");
|
|
150
|
+
expect(indexContent).toContain("[Cursor]");
|
|
151
|
+
expect(indexContent).toContain("[Vibe Coding]");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("Teardown → Wiki Integration", () => {
|
|
156
|
+
it("teardown intel flows into wiki pipeline", async () => {
|
|
157
|
+
await initPipeline(testDir);
|
|
158
|
+
|
|
159
|
+
// Simulate a video teardown result being ingested as intel
|
|
160
|
+
const teardownIntel: IntelItem = {
|
|
161
|
+
title: "拆解: vibe-coding教程视频分析",
|
|
162
|
+
domain: "content-strategy",
|
|
163
|
+
source: "manual",
|
|
164
|
+
collectedAt: new Date().toISOString(),
|
|
165
|
+
relevance: 90,
|
|
166
|
+
tags: ["teardown", "video", "vibe-coding"],
|
|
167
|
+
expiresAfter: 365,
|
|
168
|
+
summary: "对标账号的vibe-coding教程视频拆解。传播学:信息不对称设计精妙。心理学:认知负荷控制好。内容结构:承诺-兑现完美匹配。视听语言:HKRR主导K维度。",
|
|
169
|
+
keyPoints: [
|
|
170
|
+
"开场3秒用反常识钩子",
|
|
171
|
+
"信息密度曲线合理",
|
|
172
|
+
"HKRR纯粹K维度",
|
|
173
|
+
"框架效应:frame成能力边界变化",
|
|
174
|
+
],
|
|
175
|
+
topicPotential: "可借鉴:反常识开场+承诺兑现+纯粹K维度",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await saveIntel(teardownIntel, testDir);
|
|
179
|
+
|
|
180
|
+
// Verify it's in the intel library
|
|
181
|
+
const items = await listIntel("content-strategy", testDir);
|
|
182
|
+
expect(items.length).toBe(1);
|
|
183
|
+
expect(items[0].tags).toContain("teardown");
|
|
184
|
+
expect(items[0].tags).toContain("video");
|
|
185
|
+
|
|
186
|
+
// Simulate wiki page creation from teardown intel
|
|
187
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
188
|
+
const wikiPage: WikiPage = {
|
|
189
|
+
type: "concept",
|
|
190
|
+
title: "Vibe Coding Teardown Insights",
|
|
191
|
+
aliases: ["vibe coding analysis"],
|
|
192
|
+
related: [],
|
|
193
|
+
sources: [`content-strategy/${today}-chai-jie-vibe-coding-jiao-cheng-shi-pin-fen-xi.md`],
|
|
194
|
+
created: today,
|
|
195
|
+
updated: today,
|
|
196
|
+
body: "# Vibe Coding Teardown Insights\n\n从对标视频拆解中提取:反常识开场+承诺兑现是有效公式。",
|
|
197
|
+
};
|
|
198
|
+
await saveWikiPage(wikiPage, testDir);
|
|
199
|
+
|
|
200
|
+
// Verify wiki page references the teardown
|
|
201
|
+
const loaded = await getWikiPage("vibe-coding-teardown-insights", testDir);
|
|
202
|
+
expect(loaded).not.toBeNull();
|
|
203
|
+
expect(loaded!.sources[0]).toContain("content-strategy");
|
|
204
|
+
|
|
205
|
+
// Verify it shows up in index
|
|
206
|
+
await regenerateWikiIndex(testDir);
|
|
207
|
+
const indexContent = await fs.readFile(
|
|
208
|
+
path.join(stagePath("wiki", testDir), "index.md"),
|
|
209
|
+
"utf-8",
|
|
210
|
+
);
|
|
211
|
+
expect(indexContent).toContain("[Vibe Coding Teardown Insights]");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -25,8 +25,14 @@ import {
|
|
|
25
25
|
restoreProject,
|
|
26
26
|
getProjectMeta,
|
|
27
27
|
listProjects,
|
|
28
|
+
saveWikiPage,
|
|
29
|
+
getWikiPage,
|
|
30
|
+
listWikiPages,
|
|
31
|
+
regenerateWikiIndex,
|
|
32
|
+
appendWikiLog,
|
|
28
33
|
type IntelItem,
|
|
29
34
|
type TopicCandidate,
|
|
35
|
+
type WikiPage,
|
|
30
36
|
} from "../storage/pipeline-store.js";
|
|
31
37
|
|
|
32
38
|
let testDir: string;
|
|
@@ -105,6 +111,12 @@ describe("Pipeline Init", () => {
|
|
|
105
111
|
expect(archive.isDirectory()).toBe(true);
|
|
106
112
|
});
|
|
107
113
|
|
|
114
|
+
it("creates wiki directory", async () => {
|
|
115
|
+
await initPipeline(testDir);
|
|
116
|
+
const stat = await fs.stat(stagePath("wiki", testDir));
|
|
117
|
+
expect(stat.isDirectory()).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
108
120
|
it("initPipeline is idempotent", async () => {
|
|
109
121
|
await initPipeline(testDir);
|
|
110
122
|
await initPipeline(testDir);
|
|
@@ -291,12 +303,13 @@ describe("Project Lifecycle", () => {
|
|
|
291
303
|
const topics = await listTopics(undefined, testDir);
|
|
292
304
|
expect(topics.length).toBe(0);
|
|
293
305
|
|
|
294
|
-
// Project dir should contain meta.yaml, draft
|
|
306
|
+
// Project dir should contain meta.yaml, draft.md (live), references/.
|
|
307
|
+
// No draft-v*.md exists yet — snapshots are created only when revisions replace draft.md.
|
|
295
308
|
const files = await fs.readdir(projectDir);
|
|
296
309
|
expect(files).toContain("meta.yaml");
|
|
297
|
-
expect(files).toContain("draft-v1.md");
|
|
298
310
|
expect(files).toContain("draft.md");
|
|
299
311
|
expect(files).toContain("references");
|
|
312
|
+
expect(files.filter((f) => f.startsWith("draft-v"))).toHaveLength(0);
|
|
300
313
|
});
|
|
301
314
|
|
|
302
315
|
it("advances project from drafting to production", async () => {
|
|
@@ -318,19 +331,37 @@ describe("Project Lifecycle", () => {
|
|
|
318
331
|
await startProject("版本测试", testDir);
|
|
319
332
|
|
|
320
333
|
const projectName = slugify("版本测试");
|
|
334
|
+
const projectDir = path.join(stagePath("drafting", testDir), projectName);
|
|
335
|
+
|
|
336
|
+
// Capture the initial live content before revising
|
|
337
|
+
const fsp = await import("node:fs/promises");
|
|
338
|
+
const originalContent = await fsp.readFile(
|
|
339
|
+
path.join(projectDir, "draft.md"),
|
|
340
|
+
"utf-8",
|
|
341
|
+
);
|
|
342
|
+
|
|
321
343
|
await addDraftVersion(projectName, "# V2 内容", "second draft", testDir);
|
|
322
344
|
|
|
323
345
|
const meta = await getProjectMeta(projectName, testDir);
|
|
324
|
-
|
|
325
|
-
expect(meta!.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
346
|
+
// One snapshot should have been created (archiving the original)
|
|
347
|
+
expect(meta!.versions.length).toBe(1);
|
|
348
|
+
expect(meta!.versions[0].file).toBe("draft-v1.md");
|
|
349
|
+
expect(meta!.versions[0].note).toBe("second draft");
|
|
350
|
+
expect(meta!.current).toBe("draft.md");
|
|
351
|
+
|
|
352
|
+
// draft.md is now the new content
|
|
353
|
+
const liveContent = await fsp.readFile(
|
|
354
|
+
path.join(projectDir, "draft.md"),
|
|
355
|
+
"utf-8",
|
|
356
|
+
);
|
|
357
|
+
expect(liveContent).toBe("# V2 内容");
|
|
358
|
+
|
|
359
|
+
// draft-v1.md holds the frozen original
|
|
360
|
+
const archivedContent = await fsp.readFile(
|
|
361
|
+
path.join(projectDir, "draft-v1.md"),
|
|
362
|
+
"utf-8",
|
|
332
363
|
);
|
|
333
|
-
expect(
|
|
364
|
+
expect(archivedContent).toBe(originalContent);
|
|
334
365
|
});
|
|
335
366
|
|
|
336
367
|
it("trashes and restores project", async () => {
|
|
@@ -361,3 +392,79 @@ describe("Project Lifecycle", () => {
|
|
|
361
392
|
expect(projects.length).toBeGreaterThan(0);
|
|
362
393
|
});
|
|
363
394
|
});
|
|
395
|
+
|
|
396
|
+
// ─── Wiki Storage ──────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function makeWikiPage(overrides: Partial<WikiPage> = {}): WikiPage {
|
|
399
|
+
return {
|
|
400
|
+
type: "entity",
|
|
401
|
+
title: "Claude AI",
|
|
402
|
+
aliases: ["Claude", "Anthropic Claude"],
|
|
403
|
+
related: ["anthropic", "llm"],
|
|
404
|
+
sources: ["intel/2024-01-15-claude.md"],
|
|
405
|
+
created: "2026-04-05T00:00:00.000Z",
|
|
406
|
+
updated: "2026-04-05T00:00:00.000Z",
|
|
407
|
+
body: "Claude is an AI assistant made by Anthropic.",
|
|
408
|
+
...overrides,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
describe("Wiki Storage", () => {
|
|
413
|
+
it("saves and reads a wiki page", async () => {
|
|
414
|
+
const page = makeWikiPage();
|
|
415
|
+
const filePath = await saveWikiPage(page, testDir);
|
|
416
|
+
expect(filePath).toContain("wiki");
|
|
417
|
+
expect(filePath.endsWith(".md")).toBe(true);
|
|
418
|
+
|
|
419
|
+
const slug = "claude-ai";
|
|
420
|
+
const loaded = await getWikiPage(slug, testDir);
|
|
421
|
+
expect(loaded).not.toBeNull();
|
|
422
|
+
expect(loaded!.title).toBe(page.title);
|
|
423
|
+
expect(loaded!.type).toBe(page.type);
|
|
424
|
+
expect(loaded!.aliases).toEqual(page.aliases);
|
|
425
|
+
expect(loaded!.related).toEqual(page.related);
|
|
426
|
+
expect(loaded!.sources).toEqual(page.sources);
|
|
427
|
+
expect(loaded!.body).toBe(page.body);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("lists all wiki pages", async () => {
|
|
431
|
+
await saveWikiPage(makeWikiPage({ title: "Claude AI" }), testDir);
|
|
432
|
+
await saveWikiPage(
|
|
433
|
+
makeWikiPage({ title: "GPT-4", type: "comparison" }),
|
|
434
|
+
testDir,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const pages = await listWikiPages(testDir);
|
|
438
|
+
expect(pages.length).toBe(2);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("generates index.md grouped by type", async () => {
|
|
442
|
+
await saveWikiPage(makeWikiPage({ title: "Claude AI", type: "entity" }), testDir);
|
|
443
|
+
await saveWikiPage(makeWikiPage({ title: "LLM Basics", type: "concept" }), testDir);
|
|
444
|
+
await saveWikiPage(makeWikiPage({ title: "Claude vs GPT", type: "comparison" }), testDir);
|
|
445
|
+
|
|
446
|
+
await regenerateWikiIndex(testDir);
|
|
447
|
+
|
|
448
|
+
const indexPath = path.join(stagePath("wiki", testDir), "index.md");
|
|
449
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
450
|
+
expect(content).toContain("# AutoCrew Knowledge Wiki");
|
|
451
|
+
expect(content).toContain("## Entities");
|
|
452
|
+
expect(content).toContain("## Concepts");
|
|
453
|
+
expect(content).toContain("## Comparisons");
|
|
454
|
+
expect(content).toContain("[Claude AI]");
|
|
455
|
+
expect(content).toContain("[LLM Basics]");
|
|
456
|
+
expect(content).toContain("[Claude vs GPT]");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("appends to log.md", async () => {
|
|
460
|
+
await appendWikiLog("create", "Created page: Claude AI", testDir);
|
|
461
|
+
await appendWikiLog("update", "Updated page: Claude AI", testDir);
|
|
462
|
+
|
|
463
|
+
const logPath = path.join(stagePath("wiki", testDir), "log.md");
|
|
464
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
465
|
+
const lines = content.trim().split("\n");
|
|
466
|
+
expect(lines.length).toBe(2);
|
|
467
|
+
expect(lines[0]).toContain("[create]");
|
|
468
|
+
expect(lines[1]).toContain("[update]");
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -8,6 +8,7 @@ import yaml from "js-yaml";
|
|
|
8
8
|
export const PIPELINE_STAGES = [
|
|
9
9
|
"intel",
|
|
10
10
|
"topics",
|
|
11
|
+
"wiki",
|
|
11
12
|
"drafting",
|
|
12
13
|
"production",
|
|
13
14
|
"published",
|
|
@@ -38,6 +39,17 @@ export interface IntelItem {
|
|
|
38
39
|
topicPotential: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
export interface WikiPage {
|
|
43
|
+
type: "entity" | "concept" | "comparison";
|
|
44
|
+
title: string;
|
|
45
|
+
aliases: string[];
|
|
46
|
+
related: string[];
|
|
47
|
+
sources: string[];
|
|
48
|
+
created: string;
|
|
49
|
+
updated: string;
|
|
50
|
+
body: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
export interface TopicScore {
|
|
42
54
|
heat: number;
|
|
43
55
|
differentiation: number;
|
|
@@ -330,6 +342,131 @@ export async function archiveExpiredIntel(
|
|
|
330
342
|
return { archived };
|
|
331
343
|
}
|
|
332
344
|
|
|
345
|
+
// ─── Wiki Storage ──────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
export function wikiPageToMarkdown(page: WikiPage): string {
|
|
348
|
+
const frontmatter: Record<string, unknown> = {
|
|
349
|
+
type: page.type,
|
|
350
|
+
title: page.title,
|
|
351
|
+
aliases: page.aliases,
|
|
352
|
+
related: page.related,
|
|
353
|
+
sources: page.sources,
|
|
354
|
+
created: page.created,
|
|
355
|
+
updated: page.updated,
|
|
356
|
+
};
|
|
357
|
+
const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd();
|
|
358
|
+
return `---\n${yamlStr}\n---\n\n${page.body}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function parseWikiPage(content: string): WikiPage {
|
|
362
|
+
const { data, content: body } = matter(content);
|
|
363
|
+
return {
|
|
364
|
+
type: data.type,
|
|
365
|
+
title: data.title,
|
|
366
|
+
aliases: data.aliases ?? [],
|
|
367
|
+
related: data.related ?? [],
|
|
368
|
+
sources: data.sources ?? [],
|
|
369
|
+
created: data.created,
|
|
370
|
+
updated: data.updated,
|
|
371
|
+
body: body.trim(),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function saveWikiPage(
|
|
376
|
+
page: WikiPage,
|
|
377
|
+
dataDir?: string,
|
|
378
|
+
): Promise<string> {
|
|
379
|
+
await initPipeline(dataDir);
|
|
380
|
+
const wikiDir = stagePath("wiki", dataDir);
|
|
381
|
+
const slug = slugify(page.title);
|
|
382
|
+
const filePath = path.join(wikiDir, `${slug}.md`);
|
|
383
|
+
await fs.writeFile(filePath, wikiPageToMarkdown(page), "utf-8");
|
|
384
|
+
return filePath;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function getWikiPage(
|
|
388
|
+
slug: string,
|
|
389
|
+
dataDir?: string,
|
|
390
|
+
): Promise<WikiPage | null> {
|
|
391
|
+
const wikiDir = stagePath("wiki", dataDir);
|
|
392
|
+
const filePath = path.join(wikiDir, `${slug}.md`);
|
|
393
|
+
try {
|
|
394
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
395
|
+
return parseWikiPage(content);
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function listWikiPages(
|
|
402
|
+
dataDir?: string,
|
|
403
|
+
): Promise<WikiPage[]> {
|
|
404
|
+
const wikiDir = stagePath("wiki", dataDir);
|
|
405
|
+
const pages: WikiPage[] = [];
|
|
406
|
+
let files: string[];
|
|
407
|
+
try {
|
|
408
|
+
files = await fs.readdir(wikiDir);
|
|
409
|
+
} catch {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
for (const f of files) {
|
|
413
|
+
if (!f.endsWith(".md") || f === "index.md" || f === "log.md") continue;
|
|
414
|
+
const content = await fs.readFile(path.join(wikiDir, f), "utf-8");
|
|
415
|
+
pages.push(parseWikiPage(content));
|
|
416
|
+
}
|
|
417
|
+
return pages;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function regenerateWikiIndex(
|
|
421
|
+
dataDir?: string,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
const pages = await listWikiPages(dataDir);
|
|
424
|
+
const grouped: Record<string, WikiPage[]> = { entity: [], concept: [], comparison: [] };
|
|
425
|
+
for (const page of pages) {
|
|
426
|
+
(grouped[page.type] ??= []).push(page);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const headings: Record<string, string> = {
|
|
430
|
+
entity: "Entities",
|
|
431
|
+
concept: "Concepts",
|
|
432
|
+
comparison: "Comparisons",
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const lines = ["# AutoCrew Knowledge Wiki", ""];
|
|
436
|
+
for (const [type, heading] of Object.entries(headings)) {
|
|
437
|
+
const group = grouped[type] ?? [];
|
|
438
|
+
if (group.length === 0) continue;
|
|
439
|
+
lines.push(`## ${heading}`);
|
|
440
|
+
for (const page of group.sort((a, b) => a.title.localeCompare(b.title))) {
|
|
441
|
+
const slug = slugify(page.title);
|
|
442
|
+
const bodyLines = page.body.split("\n").filter((l) => !l.startsWith("#") && l.trim());
|
|
443
|
+
const summary = bodyLines[0]?.slice(0, 60) ?? "";
|
|
444
|
+
lines.push(`- [${page.title}](${slug}.md) — ${summary}`);
|
|
445
|
+
}
|
|
446
|
+
lines.push("");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const wikiDir = stagePath("wiki", dataDir);
|
|
450
|
+
await fs.writeFile(path.join(wikiDir, "index.md"), lines.join("\n"), "utf-8");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function appendWikiLog(
|
|
454
|
+
operation: string,
|
|
455
|
+
description: string,
|
|
456
|
+
dataDir?: string,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
await initPipeline(dataDir);
|
|
459
|
+
const wikiDir = stagePath("wiki", dataDir);
|
|
460
|
+
const logPath = path.join(wikiDir, "log.md");
|
|
461
|
+
const timestamp = new Date().toISOString();
|
|
462
|
+
const entry = `- ${timestamp} [${operation}] ${description}\n`;
|
|
463
|
+
try {
|
|
464
|
+
await fs.appendFile(logPath, entry, "utf-8");
|
|
465
|
+
} catch {
|
|
466
|
+
await fs.writeFile(logPath, entry, "utf-8");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
333
470
|
// ─── Topic Pool ─────────────────────────────────────────────────────────────
|
|
334
471
|
|
|
335
472
|
export function topicToMarkdown(topic: TopicCandidate): string {
|
|
@@ -511,7 +648,7 @@ export async function findProject(
|
|
|
511
648
|
dataDir?: string,
|
|
512
649
|
): Promise<{ dir: string; stage: PipelineStage } | null> {
|
|
513
650
|
for (const stage of PIPELINE_STAGES) {
|
|
514
|
-
if (stage === "intel" || stage === "topics") continue;
|
|
651
|
+
if (stage === "intel" || stage === "topics" || stage === "wiki") continue;
|
|
515
652
|
const dir = path.join(stagePath(stage, dataDir), name);
|
|
516
653
|
try {
|
|
517
654
|
const stat = await fs.stat(dir);
|
|
@@ -550,6 +687,9 @@ export async function startProject(
|
|
|
550
687
|
|
|
551
688
|
const now = new Date().toISOString();
|
|
552
689
|
|
|
690
|
+
// Semantics: draft.md = live current working file (always latest),
|
|
691
|
+
// draft-v{N}.md = immutable snapshots of content that has been REPLACED by a revision.
|
|
692
|
+
// On initial create there are no snapshots yet — only draft.md exists.
|
|
553
693
|
const meta: ProjectMeta = {
|
|
554
694
|
title: topic.title,
|
|
555
695
|
domain: topic.domain,
|
|
@@ -557,18 +697,13 @@ export async function startProject(
|
|
|
557
697
|
createdAt: now,
|
|
558
698
|
sourceTopic: topicFile,
|
|
559
699
|
intelRefs: topic.intelRefs,
|
|
560
|
-
versions: [
|
|
561
|
-
current: "draft
|
|
700
|
+
versions: [],
|
|
701
|
+
current: "draft.md",
|
|
562
702
|
history: [{ stage: "drafting", entered: now }],
|
|
563
703
|
platforms: [],
|
|
564
704
|
};
|
|
565
705
|
|
|
566
706
|
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
707
|
await fs.writeFile(
|
|
573
708
|
path.join(projectDir, "draft.md"),
|
|
574
709
|
`# ${topic.title}\n\n`,
|
|
@@ -617,22 +752,42 @@ export async function addDraftVersion(
|
|
|
617
752
|
if (!found) throw new Error(`Project not found: ${name}`);
|
|
618
753
|
|
|
619
754
|
const meta = await readMeta(found.dir);
|
|
620
|
-
const
|
|
621
|
-
|
|
755
|
+
const draftPath = path.join(found.dir, "draft.md");
|
|
756
|
+
|
|
757
|
+
// Archive the PREVIOUS live draft.md into an immutable snapshot
|
|
758
|
+
// before overwriting it with the new revision. This is the core invariant:
|
|
759
|
+
// draft.md = always the latest live working file
|
|
760
|
+
// draft-v{N}.md = frozen historical states that have been replaced
|
|
761
|
+
let oldContent = "";
|
|
762
|
+
try {
|
|
763
|
+
oldContent = await fs.readFile(draftPath, "utf-8");
|
|
764
|
+
} catch {
|
|
765
|
+
// No previous draft.md — treat as empty. Shouldn't happen in normal flow.
|
|
766
|
+
}
|
|
622
767
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
768
|
+
const versionNum = meta.versions.length + 1;
|
|
769
|
+
const archiveFilename = `draft-v${versionNum}.md`;
|
|
770
|
+
|
|
771
|
+
// Snapshot the OLD content (only if there is old content to preserve)
|
|
772
|
+
if (oldContent.length > 0) {
|
|
773
|
+
await fs.writeFile(
|
|
774
|
+
path.join(found.dir, archiveFilename),
|
|
775
|
+
oldContent,
|
|
776
|
+
"utf-8",
|
|
777
|
+
);
|
|
778
|
+
meta.versions.push({
|
|
779
|
+
file: archiveFilename,
|
|
780
|
+
createdAt: new Date().toISOString(),
|
|
781
|
+
note,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
626
784
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
note,
|
|
631
|
-
});
|
|
632
|
-
meta.current = filename;
|
|
785
|
+
// Write the new content as the live draft
|
|
786
|
+
await fs.writeFile(draftPath, content, "utf-8");
|
|
787
|
+
meta.current = "draft.md";
|
|
633
788
|
await writeMeta(found.dir, meta);
|
|
634
789
|
|
|
635
|
-
return
|
|
790
|
+
return draftPath;
|
|
636
791
|
}
|
|
637
792
|
|
|
638
793
|
export async function trashProject(
|