autocrew 0.1.0 → 0.3.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/configure/SKILL.md +228 -0
- 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 +13 -2
- package/skills/teardown/SKILL.md +256 -43
- package/skills/write-script/SKILL.md +474 -105
- package/src/modules/config/migrate.test.ts +111 -0
- package/src/modules/config/migrate.ts +83 -0
- package/src/modules/config/service-config.test.ts +140 -0
- package/src/modules/config/service-config.ts +139 -0
- package/src/modules/intel/integration.test.ts +11 -8
- 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 +121 -11
- package/src/storage/pipeline-store.ts +212 -20
- package/src/tools/content-save.ts +61 -10
- package/src/tools/intel.test.ts +61 -0
- package/src/tools/intel.ts +111 -3
- package/src/tools/registry.ts +2 -1
|
@@ -0,0 +1,111 @@
|
|
|
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 { migrateProfileToServices } from "./migrate.js";
|
|
6
|
+
|
|
7
|
+
let testDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-migrate-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("migrateProfileToServices", () => {
|
|
18
|
+
it("moves omniConfig and videoCrawler from profile to services", async () => {
|
|
19
|
+
const profile = {
|
|
20
|
+
industry: "tech",
|
|
21
|
+
platforms: ["xhs"],
|
|
22
|
+
contentTypes: ["video"],
|
|
23
|
+
tone: "casual",
|
|
24
|
+
omniConfig: {
|
|
25
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
26
|
+
model: "mimo-v2-omni",
|
|
27
|
+
apiKey: "sk-omni-test-123",
|
|
28
|
+
},
|
|
29
|
+
videoCrawler: {
|
|
30
|
+
type: "mediacrawl",
|
|
31
|
+
command: "python3 /opt/mediacrawl/main.py",
|
|
32
|
+
},
|
|
33
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
35
|
+
};
|
|
36
|
+
await fs.writeFile(
|
|
37
|
+
path.join(testDir, "creator-profile.json"),
|
|
38
|
+
JSON.stringify(profile, null, 2),
|
|
39
|
+
"utf-8",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const result = await migrateProfileToServices(testDir);
|
|
43
|
+
expect(result.migrated).toBe(true);
|
|
44
|
+
|
|
45
|
+
// Verify services.json was created with correct data
|
|
46
|
+
const svcRaw = await fs.readFile(path.join(testDir, "services.json"), "utf-8");
|
|
47
|
+
const svc = JSON.parse(svcRaw);
|
|
48
|
+
expect(svc.omni.provider).toBe("xiaomi");
|
|
49
|
+
expect(svc.omni.apiKey).toBe("sk-omni-test-123");
|
|
50
|
+
expect(svc.omni.baseUrl).toBe("https://api.xiaomimimo.com/v1");
|
|
51
|
+
expect(svc.omni.model).toBe("mimo-v2-omni");
|
|
52
|
+
expect(svc.videoCrawler.type).toBe("mediacrawl");
|
|
53
|
+
expect(svc.videoCrawler.command).toBe("python3 /opt/mediacrawl/main.py");
|
|
54
|
+
|
|
55
|
+
// Verify profile was cleaned up
|
|
56
|
+
const profileRaw = await fs.readFile(path.join(testDir, "creator-profile.json"), "utf-8");
|
|
57
|
+
const updatedProfile = JSON.parse(profileRaw);
|
|
58
|
+
expect(updatedProfile.omniConfig).toBeUndefined();
|
|
59
|
+
expect(updatedProfile.videoCrawler).toBeUndefined();
|
|
60
|
+
expect(updatedProfile.industry).toBe("tech");
|
|
61
|
+
expect(updatedProfile.updatedAt).not.toBe("2026-01-01T00:00:00.000Z");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips migration when services.json already exists", async () => {
|
|
65
|
+
const profile = {
|
|
66
|
+
industry: "tech",
|
|
67
|
+
platforms: ["xhs"],
|
|
68
|
+
omniConfig: {
|
|
69
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
70
|
+
model: "mimo-v2-omni",
|
|
71
|
+
apiKey: "sk-omni-test-123",
|
|
72
|
+
},
|
|
73
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
74
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
75
|
+
};
|
|
76
|
+
await fs.writeFile(
|
|
77
|
+
path.join(testDir, "creator-profile.json"),
|
|
78
|
+
JSON.stringify(profile, null, 2),
|
|
79
|
+
"utf-8",
|
|
80
|
+
);
|
|
81
|
+
await fs.writeFile(
|
|
82
|
+
path.join(testDir, "services.json"),
|
|
83
|
+
JSON.stringify({ configuredAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
|
|
84
|
+
"utf-8",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const result = await migrateProfileToServices(testDir);
|
|
88
|
+
expect(result.migrated).toBe(false);
|
|
89
|
+
expect(result.reason).toContain("already exists");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles profile without old fields gracefully", async () => {
|
|
93
|
+
const profile = {
|
|
94
|
+
industry: "tech",
|
|
95
|
+
platforms: ["xhs"],
|
|
96
|
+
contentTypes: ["video"],
|
|
97
|
+
tone: "casual",
|
|
98
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
99
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
100
|
+
};
|
|
101
|
+
await fs.writeFile(
|
|
102
|
+
path.join(testDir, "creator-profile.json"),
|
|
103
|
+
JSON.stringify(profile, null, 2),
|
|
104
|
+
"utf-8",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const result = await migrateProfileToServices(testDir);
|
|
108
|
+
expect(result.migrated).toBe(false);
|
|
109
|
+
expect(result.reason).toContain("nothing to migrate");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration — moves legacy omniConfig/videoCrawler fields from
|
|
3
|
+
* creator-profile.json into the new services.json format.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { saveServiceConfig, type ServiceConfig } from "./service-config.js";
|
|
8
|
+
|
|
9
|
+
const PROFILE_FILE = "creator-profile.json";
|
|
10
|
+
const SERVICE_FILE = "services.json";
|
|
11
|
+
|
|
12
|
+
function getDataDir(customDir?: string): string {
|
|
13
|
+
if (customDir) return customDir;
|
|
14
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
15
|
+
return path.join(home, ".autocrew");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function migrateProfileToServices(
|
|
19
|
+
dataDir?: string,
|
|
20
|
+
): Promise<{ migrated: boolean; reason?: string }> {
|
|
21
|
+
const dir = getDataDir(dataDir);
|
|
22
|
+
const servicesPath = path.join(dir, SERVICE_FILE);
|
|
23
|
+
const profilePath = path.join(dir, PROFILE_FILE);
|
|
24
|
+
|
|
25
|
+
// 1. Bail if services.json already exists
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(servicesPath);
|
|
28
|
+
return { migrated: false, reason: "services.json already exists" };
|
|
29
|
+
} catch {
|
|
30
|
+
// File doesn't exist — continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Load profile as raw JSON
|
|
34
|
+
let raw: Record<string, unknown>;
|
|
35
|
+
try {
|
|
36
|
+
const content = await fs.readFile(profilePath, "utf-8");
|
|
37
|
+
raw = JSON.parse(content) as Record<string, unknown>;
|
|
38
|
+
} catch {
|
|
39
|
+
return { migrated: false, reason: "nothing to migrate" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Check if there's anything to migrate
|
|
43
|
+
if (!raw.omniConfig && !raw.videoCrawler) {
|
|
44
|
+
return { migrated: false, reason: "nothing to migrate" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 4. Build ServiceConfig from old fields
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const svcConfig: ServiceConfig = {
|
|
50
|
+
configuredAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (raw.omniConfig) {
|
|
55
|
+
const old = raw.omniConfig as Record<string, string>;
|
|
56
|
+
svcConfig.omni = {
|
|
57
|
+
provider: "xiaomi",
|
|
58
|
+
baseUrl: old.baseUrl,
|
|
59
|
+
model: old.model,
|
|
60
|
+
apiKey: old.apiKey,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (raw.videoCrawler) {
|
|
65
|
+
const old = raw.videoCrawler as Record<string, string>;
|
|
66
|
+
svcConfig.videoCrawler = {
|
|
67
|
+
type: old.type as "mediacrawl" | "playwright" | "manual",
|
|
68
|
+
command: old.command,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 5. Save new services.json
|
|
73
|
+
await saveServiceConfig(svcConfig, dataDir);
|
|
74
|
+
|
|
75
|
+
// 6. Clean up profile
|
|
76
|
+
delete raw.omniConfig;
|
|
77
|
+
delete raw.videoCrawler;
|
|
78
|
+
raw.updatedAt = now;
|
|
79
|
+
await fs.writeFile(profilePath, JSON.stringify(raw, null, 2), "utf-8");
|
|
80
|
+
|
|
81
|
+
// 7. Done
|
|
82
|
+
return { migrated: true };
|
|
83
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
loadServiceConfig,
|
|
7
|
+
saveServiceConfig,
|
|
8
|
+
detectConfigGaps,
|
|
9
|
+
type ServiceConfig,
|
|
10
|
+
} from "./service-config.js";
|
|
11
|
+
|
|
12
|
+
let testDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-svcconfig-test-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("loadServiceConfig", () => {
|
|
23
|
+
it("returns empty config when file does not exist", async () => {
|
|
24
|
+
const config = await loadServiceConfig(testDir);
|
|
25
|
+
expect(config.configuredAt).toBeTruthy();
|
|
26
|
+
expect(config.omni).toBeUndefined();
|
|
27
|
+
expect(config.coverGen).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("saveServiceConfig / loadServiceConfig round-trip", () => {
|
|
32
|
+
it("saves and loads config with omni and coverGen", async () => {
|
|
33
|
+
const config = await loadServiceConfig(testDir);
|
|
34
|
+
const full: ServiceConfig = {
|
|
35
|
+
...config,
|
|
36
|
+
omni: {
|
|
37
|
+
provider: "mimo",
|
|
38
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
39
|
+
model: "mimo-v2-omni",
|
|
40
|
+
apiKey: "sk-omni-123",
|
|
41
|
+
},
|
|
42
|
+
coverGen: {
|
|
43
|
+
provider: "flux",
|
|
44
|
+
apiKey: "sk-cover-456",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
await saveServiceConfig(full, testDir);
|
|
48
|
+
const loaded = await loadServiceConfig(testDir);
|
|
49
|
+
expect(loaded.omni?.provider).toBe("mimo");
|
|
50
|
+
expect(loaded.omni?.apiKey).toBe("sk-omni-123");
|
|
51
|
+
expect(loaded.coverGen?.provider).toBe("flux");
|
|
52
|
+
expect(loaded.coverGen?.apiKey).toBe("sk-cover-456");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("saves and loads all service modules", async () => {
|
|
56
|
+
const config = await loadServiceConfig(testDir);
|
|
57
|
+
const full: ServiceConfig = {
|
|
58
|
+
...config,
|
|
59
|
+
omni: {
|
|
60
|
+
provider: "mimo",
|
|
61
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
62
|
+
model: "mimo-v2-omni",
|
|
63
|
+
apiKey: "sk-omni-123",
|
|
64
|
+
},
|
|
65
|
+
coverGen: {
|
|
66
|
+
provider: "flux",
|
|
67
|
+
apiKey: "sk-cover-456",
|
|
68
|
+
model: "flux-pro",
|
|
69
|
+
},
|
|
70
|
+
videoCrawler: {
|
|
71
|
+
type: "mediacrawl",
|
|
72
|
+
command: "python3 /opt/mediacrawl/main.py",
|
|
73
|
+
},
|
|
74
|
+
tts: {
|
|
75
|
+
provider: "fish-audio",
|
|
76
|
+
apiKey: "sk-tts-789",
|
|
77
|
+
voice: "zh-female-1",
|
|
78
|
+
},
|
|
79
|
+
platforms: {
|
|
80
|
+
xhs: { configured: true, lastAuth: "2026-04-01T00:00:00.000Z" },
|
|
81
|
+
douyin: { configured: false },
|
|
82
|
+
},
|
|
83
|
+
intelSources: {
|
|
84
|
+
rssConfigured: true,
|
|
85
|
+
trendsConfigured: false,
|
|
86
|
+
competitorsConfigured: true,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
await saveServiceConfig(full, testDir);
|
|
90
|
+
const loaded = await loadServiceConfig(testDir);
|
|
91
|
+
expect(loaded.omni?.apiKey).toBe("sk-omni-123");
|
|
92
|
+
expect(loaded.coverGen?.model).toBe("flux-pro");
|
|
93
|
+
expect(loaded.videoCrawler?.type).toBe("mediacrawl");
|
|
94
|
+
expect(loaded.tts?.voice).toBe("zh-female-1");
|
|
95
|
+
expect(loaded.platforms?.xhs.configured).toBe(true);
|
|
96
|
+
expect(loaded.intelSources?.rssConfigured).toBe(true);
|
|
97
|
+
expect(loaded.intelSources?.competitorsConfigured).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("detectConfigGaps", () => {
|
|
102
|
+
it("reports all gaps when nothing is configured", async () => {
|
|
103
|
+
const gaps = await detectConfigGaps(testDir);
|
|
104
|
+
expect(gaps).toHaveLength(6);
|
|
105
|
+
const modules = gaps.map((g) => g.module);
|
|
106
|
+
expect(modules).toContain("omni");
|
|
107
|
+
expect(modules).toContain("coverGen");
|
|
108
|
+
expect(modules).toContain("videoCrawler");
|
|
109
|
+
expect(modules).toContain("tts");
|
|
110
|
+
expect(modules).toContain("platforms");
|
|
111
|
+
expect(modules).toContain("intelSources");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reports no gap for configured modules", async () => {
|
|
115
|
+
const config = await loadServiceConfig(testDir);
|
|
116
|
+
const withOmni: ServiceConfig = {
|
|
117
|
+
...config,
|
|
118
|
+
omni: {
|
|
119
|
+
provider: "mimo",
|
|
120
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
121
|
+
model: "mimo-v2-omni",
|
|
122
|
+
apiKey: "sk-omni-123",
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
await saveServiceConfig(withOmni, testDir);
|
|
126
|
+
const gaps = await detectConfigGaps(testDir);
|
|
127
|
+
const modules = gaps.map((g) => g.module);
|
|
128
|
+
expect(modules).not.toContain("omni");
|
|
129
|
+
expect(modules).toContain("coverGen");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("each gap has module, feature, and impact", async () => {
|
|
133
|
+
const gaps = await detectConfigGaps(testDir);
|
|
134
|
+
for (const gap of gaps) {
|
|
135
|
+
expect(gap.module).toBeTruthy();
|
|
136
|
+
expect(gap.feature).toBeTruthy();
|
|
137
|
+
expect(gap.impact).toBeTruthy();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Config — Tool/API configurations stored at ~/.autocrew/services.json
|
|
3
|
+
*
|
|
4
|
+
* Separate from creator-profile.json. This file tracks which external services
|
|
5
|
+
* (LLM, cover gen, TTS, platforms, etc.) are configured and ready to use.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface OmniServiceConfig {
|
|
11
|
+
provider: string;
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
model: string;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CoverGenConfig {
|
|
18
|
+
provider: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VideoCrawlerServiceConfig {
|
|
24
|
+
type: "mediacrawl" | "playwright" | "manual";
|
|
25
|
+
command?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TTSConfig {
|
|
29
|
+
provider: string;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
apiKey: string;
|
|
32
|
+
voice?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PlatformAuthStatus {
|
|
36
|
+
configured: boolean;
|
|
37
|
+
lastAuth?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IntelSourcesStatus {
|
|
41
|
+
rssConfigured: boolean;
|
|
42
|
+
trendsConfigured: boolean;
|
|
43
|
+
competitorsConfigured: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ServiceConfig {
|
|
47
|
+
omni?: OmniServiceConfig;
|
|
48
|
+
coverGen?: CoverGenConfig;
|
|
49
|
+
videoCrawler?: VideoCrawlerServiceConfig;
|
|
50
|
+
tts?: TTSConfig;
|
|
51
|
+
platforms?: Record<string, PlatformAuthStatus>;
|
|
52
|
+
intelSources?: IntelSourcesStatus;
|
|
53
|
+
configuredAt: string;
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ConfigGap {
|
|
58
|
+
module: string;
|
|
59
|
+
feature: string;
|
|
60
|
+
impact: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SERVICE_FILE = "services.json";
|
|
64
|
+
|
|
65
|
+
function getDataDir(customDir?: string): string {
|
|
66
|
+
if (customDir) return customDir;
|
|
67
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
68
|
+
return path.join(home, ".autocrew");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function emptyServiceConfig(): ServiceConfig {
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
return {
|
|
74
|
+
configuredAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load the service config. Returns empty config if file doesn't exist.
|
|
81
|
+
*/
|
|
82
|
+
export async function loadServiceConfig(dataDir?: string): Promise<ServiceConfig> {
|
|
83
|
+
const filePath = path.join(getDataDir(dataDir), SERVICE_FILE);
|
|
84
|
+
try {
|
|
85
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
86
|
+
return JSON.parse(raw) as ServiceConfig;
|
|
87
|
+
} catch {
|
|
88
|
+
return emptyServiceConfig();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Save the service config (overwrite). Updates the updatedAt timestamp.
|
|
94
|
+
*/
|
|
95
|
+
export async function saveServiceConfig(config: ServiceConfig, dataDir?: string): Promise<void> {
|
|
96
|
+
const dir = getDataDir(dataDir);
|
|
97
|
+
await fs.mkdir(dir, { recursive: true });
|
|
98
|
+
config.updatedAt = new Date().toISOString();
|
|
99
|
+
await fs.writeFile(path.join(dir, SERVICE_FILE), JSON.stringify(config, null, 2), "utf-8");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Detect which service modules are unconfigured.
|
|
104
|
+
* Returns a list of gaps with module name, feature description, and impact.
|
|
105
|
+
*/
|
|
106
|
+
export async function detectConfigGaps(dataDir?: string): Promise<ConfigGap[]> {
|
|
107
|
+
const c = await loadServiceConfig(dataDir);
|
|
108
|
+
const gaps: ConfigGap[] = [];
|
|
109
|
+
|
|
110
|
+
if (!c.omni?.apiKey) {
|
|
111
|
+
gaps.push({ module: "omni", feature: "视频分析 (Omni)", impact: "视频拆解功能不可用" });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!c.coverGen?.apiKey) {
|
|
115
|
+
gaps.push({ module: "coverGen", feature: "封面生成", impact: "AI 封面生成不可用" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!(c.videoCrawler && c.videoCrawler.type !== "manual")) {
|
|
119
|
+
gaps.push({ module: "videoCrawler", feature: "视频采集器", impact: "视频链接下载需手动操作" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!c.tts?.apiKey) {
|
|
123
|
+
gaps.push({ module: "tts", feature: "TTS 语音合成", impact: "视频配音不可用" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hasConfiguredPlatform = c.platforms
|
|
127
|
+
&& Object.values(c.platforms).some((p) => p.configured);
|
|
128
|
+
if (!hasConfiguredPlatform) {
|
|
129
|
+
gaps.push({ module: "platforms", feature: "发布平台", impact: "自动发布不可用" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hasIntelSource = c.intelSources
|
|
133
|
+
&& (c.intelSources.rssConfigured || c.intelSources.trendsConfigured || c.intelSources.competitorsConfigured);
|
|
134
|
+
if (!hasIntelSource) {
|
|
135
|
+
gaps.push({ module: "intelSources", feature: "情报源", impact: "RSS/趋势/竞品监控为空" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return gaps;
|
|
139
|
+
}
|
|
@@ -112,24 +112,27 @@ describe("Pipeline Integration — full flow", () => {
|
|
|
112
112
|
|
|
113
113
|
const files = await fs.readdir(projectDir);
|
|
114
114
|
expect(files).toContain("meta.yaml");
|
|
115
|
-
expect(files).toContain("draft-v1.md");
|
|
116
115
|
expect(files).toContain("draft.md");
|
|
116
|
+
// No draft-v*.md exists on initial create — only after revisions replace draft.md
|
|
117
|
+
expect(files.filter((f) => f.startsWith("draft-v"))).toHaveLength(0);
|
|
117
118
|
|
|
118
119
|
const projectName = slugify("高分选题");
|
|
119
120
|
|
|
120
|
-
// 5. Add draft version →
|
|
121
|
+
// 5. Add draft version → original content archived to draft-v1.md, draft.md updated
|
|
122
|
+
// Content must be ≥100 chars to pass the drafting→production gate
|
|
121
123
|
await addDraftVersion(
|
|
122
124
|
projectName,
|
|
123
|
-
"# 高分选题\n\n
|
|
125
|
+
"# 高分选题\n\n这是一篇经过深度调研的内容,包含具体案例分析、数据支撑和可操作的建议。第二版在第一版基础上增加了更多具体的AI工具评测数据和用户反馈,覆盖了Cursor、Claude Code、Windsurf三款主流AI编程工具的横向对比。",
|
|
124
126
|
"improved draft",
|
|
125
127
|
testDir,
|
|
126
128
|
);
|
|
127
129
|
|
|
128
130
|
let meta = await getProjectMeta(projectName, testDir);
|
|
129
131
|
expect(meta).not.toBeNull();
|
|
130
|
-
expect(meta!.versions.length).toBe(
|
|
131
|
-
expect(meta!.
|
|
132
|
-
expect(meta!.
|
|
132
|
+
expect(meta!.versions.length).toBe(1);
|
|
133
|
+
expect(meta!.versions[0].file).toBe("draft-v1.md");
|
|
134
|
+
expect(meta!.current).toBe("draft.md");
|
|
135
|
+
expect(meta!.versions[0].note).toBe("improved draft");
|
|
133
136
|
|
|
134
137
|
// 6. Advance: drafting → production → published
|
|
135
138
|
await advanceProject(projectName, testDir);
|
|
@@ -183,8 +186,8 @@ describe("Pipeline Integration — trash and restore", () => {
|
|
|
183
186
|
// Verify state is preserved
|
|
184
187
|
const meta = await getProjectMeta(projectName, testDir);
|
|
185
188
|
expect(meta).not.toBeNull();
|
|
186
|
-
expect(meta!.versions.length).toBe(
|
|
187
|
-
expect(meta!.current).toBe("draft
|
|
189
|
+
expect(meta!.versions.length).toBe(1);
|
|
190
|
+
expect(meta!.current).toBe("draft.md");
|
|
188
191
|
expect(meta!.title).toBe("回收还原测试");
|
|
189
192
|
|
|
190
193
|
// History should show: drafting → trash → drafting
|
|
@@ -142,6 +142,57 @@ describe("addCompetitor", () => {
|
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
describe("videoCrawler and omniConfig", () => {
|
|
146
|
+
it("saves and loads videoCrawler and omniConfig", async () => {
|
|
147
|
+
const profile = await initProfile(testDir);
|
|
148
|
+
const full: CreatorProfile = {
|
|
149
|
+
...profile,
|
|
150
|
+
videoCrawler: { type: "mediacrawl", command: "python3 /opt/mediacrawl/main.py" },
|
|
151
|
+
omniConfig: {
|
|
152
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
153
|
+
model: "mimo-v2-omni",
|
|
154
|
+
apiKey: "sk-test-key-123",
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
await saveProfile(full, testDir);
|
|
158
|
+
const loaded = await loadProfile(testDir);
|
|
159
|
+
expect(loaded).not.toBeNull();
|
|
160
|
+
expect(loaded!.videoCrawler).toEqual({ type: "mediacrawl", command: "python3 /opt/mediacrawl/main.py" });
|
|
161
|
+
expect(loaded!.omniConfig).toEqual({
|
|
162
|
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
|
163
|
+
model: "mimo-v2-omni",
|
|
164
|
+
apiKey: "sk-test-key-123",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("loads profile without video config (backward compatible)", async () => {
|
|
169
|
+
// Write a minimal JSON file without the new fields
|
|
170
|
+
const filePath = path.join(testDir, "creator-profile.json");
|
|
171
|
+
const legacy = {
|
|
172
|
+
industry: "科技",
|
|
173
|
+
platforms: ["xhs"],
|
|
174
|
+
audiencePersona: null,
|
|
175
|
+
creatorPersona: null,
|
|
176
|
+
writingRules: [],
|
|
177
|
+
styleBoundaries: { never: [], always: [] },
|
|
178
|
+
competitorAccounts: [],
|
|
179
|
+
performanceHistory: [],
|
|
180
|
+
expressionPersona: "",
|
|
181
|
+
secondaryPersonas: [],
|
|
182
|
+
styleCalibrated: false,
|
|
183
|
+
createdAt: "2025-01-01T00:00:00.000Z",
|
|
184
|
+
updatedAt: "2025-01-01T00:00:00.000Z",
|
|
185
|
+
};
|
|
186
|
+
await fs.writeFile(filePath, JSON.stringify(legacy, null, 2), "utf-8");
|
|
187
|
+
|
|
188
|
+
const loaded = await loadProfile(testDir);
|
|
189
|
+
expect(loaded).not.toBeNull();
|
|
190
|
+
expect(loaded!.industry).toBe("科技");
|
|
191
|
+
expect(loaded!.videoCrawler).toBeUndefined();
|
|
192
|
+
expect(loaded!.omniConfig).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
145
196
|
describe("detectMissingInfo", () => {
|
|
146
197
|
it("reports all missing fields on empty profile", async () => {
|
|
147
198
|
const profile = await initProfile(testDir);
|
|
@@ -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
|
/**
|