@appeeky/spec 0.0.0-stage → 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.
Files changed (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
@@ -0,0 +1,174 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
4
+ import YAML from "yaml";
5
+ import { SPEC_DIR } from "./files.js";
6
+ /**
7
+ * Clips made on this machine with the user's own fal key (FAL_KEY), the same pipeline as Appeeky's
8
+ * hosted UGC and the desktop app's local mode: the persona's look → a still in the scene → a talking
9
+ * video with the script in the persona's voice. Planning, prompts and prices come from Appeeky's
10
+ * UGC core, bundled into dist/ugc-core.js and loaded only when this runs; fal bills the user directly.
11
+ *
12
+ * Personas come from either:
13
+ * appeeky/ugc/personas/<name>.yaml a persona kept in the repo, `looks:` pointing at its images
14
+ * Appeeky UGC a persona made in Appeeky (APPEEKY_API_KEY), its cover as the look
15
+ */
16
+ export const localPersonasDir = (root) => join(root, SPEC_DIR, "ugc", "personas");
17
+ export const mediaDir = (root) => join(root, ".appeeky", "media");
18
+ /** The UGC pipeline ships inside Spec as one minified file (dist/ugc-core.js, built by scripts/vendor-ugc-core.mjs). */
19
+ export const CORE_FILE = "./ugc-core.js";
20
+ export async function loadCore(load = (s) => import(s)) {
21
+ try {
22
+ return (await load(CORE_FILE));
23
+ }
24
+ catch {
25
+ throw new Error("This build of @appeeky/spec has no UGC pipeline (dist/ugc-core.js). Update it: npm install -g @appeeky/spec@latest");
26
+ }
27
+ }
28
+ function asUgcPersona(p) {
29
+ return {
30
+ ownerEmail: "",
31
+ appId: null,
32
+ status: "active",
33
+ bio: "",
34
+ story: "",
35
+ personality: "",
36
+ appearance: "",
37
+ voice: "",
38
+ voiceId: null,
39
+ style: {},
40
+ identity: {},
41
+ signatureDetails: [],
42
+ imperfections: [],
43
+ cameraHabits: {},
44
+ voiceProfile: {},
45
+ neverChange: [],
46
+ lane: "",
47
+ formats: [],
48
+ contentPillars: [],
49
+ platforms: [],
50
+ referenceAssetIds: p.looks,
51
+ folderId: null,
52
+ createdAt: "",
53
+ updatedAt: "",
54
+ ...p,
55
+ };
56
+ }
57
+ /** A persona kept in the repo (appeeky/ugc/personas/<name>.yaml), with its looks resolved to paths or URLs. */
58
+ export async function readLocalPersona(root, name) {
59
+ const dir = localPersonasDir(root);
60
+ if (!existsSync(dir))
61
+ return undefined;
62
+ const slug = (n) => n.toLowerCase().replace(/[^a-z0-9]+/g, "-");
63
+ const hit = (await readdir(dir)).find((f) => f.endsWith(".yaml") && slug(basename(f, ".yaml")) === slug(name));
64
+ if (!hit)
65
+ return undefined;
66
+ const file = join(dir, hit);
67
+ const raw = YAML.parse(await readFile(file, "utf8"));
68
+ if (!raw?.looks?.length)
69
+ throw new Error(`${relative(root, file)} needs looks: the persona's images (paths next to the file, or URLs).`);
70
+ return {
71
+ ...raw,
72
+ id: raw.id ?? basename(file, ".yaml"),
73
+ name: raw.name ?? basename(file, ".yaml"),
74
+ looks: raw.looks.map((l) => (/^https?:\/\//.test(l) || isAbsolute(l) ? l : join(dirname(file), l))),
75
+ };
76
+ }
77
+ /** A persona made in Appeeky UGC: its fields, with the cover image as its look. */
78
+ export async function readAppeekyPersona(api, name) {
79
+ const res = await api.get("/ugc/personas");
80
+ const p = res?.personas?.find((x) => x.id === name || x.name.toLowerCase() === name.toLowerCase());
81
+ if (!p)
82
+ return undefined;
83
+ if (!p.coverUrl)
84
+ throw new Error(`${p.name} has no look in Appeeky yet: pick one in Appeeky UGC, or keep the persona in appeeky/ugc/personas/ with its images.`);
85
+ const { coverUrl, referenceAssetIds: _ids, ...rest } = p;
86
+ return { ...rest, looks: [coverUrl] };
87
+ }
88
+ /** The same two steps as the hosted clip render: a still of the persona in the scene, then a talking video. */
89
+ export function planLocalClip(core, persona, brief, refs) {
90
+ const script = brief.script.trim();
91
+ if (script.length > core.MAX_SCRIPT_CHARS)
92
+ throw new Error(`The script is too long for one clip (max ${core.MAX_SCRIPT_CHARS} characters, about 8 seconds of speech).`);
93
+ const model = core.getMediaModel("veo-3.1-fast");
94
+ if (!model)
95
+ throw new Error("The video model veo-3.1-fast is missing from @appeeky/ugc-core: update it.");
96
+ const durationSec = core.fitDuration(model, brief.durationSec ?? 8);
97
+ const aspectRatio = brief.aspectRatio ?? "9:16";
98
+ const ugcPersona = asUgcPersona(persona);
99
+ const negativePrompt = core.negativesFor(core.buildCreatorLock(ugcPersona), { video: true }).join(", ");
100
+ const scene = (brief.scene ?? "").trim();
101
+ const still = core.prepareImageJob({
102
+ ownerEmail: "",
103
+ modelId: "nano-banana",
104
+ prompt: core.stillPrompt({ persona: ugcPersona, shot: {}, direction: scene || "talking to the camera at home" }),
105
+ imageUrls: refs,
106
+ count: 1,
107
+ aspectRatio,
108
+ });
109
+ const video = (stillUrl) => core.prepareVideoJob({
110
+ ownerEmail: "",
111
+ modelId: model.id,
112
+ prompt: core.talkingVideoPrompt({ persona: ugcPersona, shot: {}, direction: scene, script }),
113
+ imageUrl: stillUrl,
114
+ durationSec,
115
+ aspectRatio,
116
+ audio: true,
117
+ negativePrompt,
118
+ });
119
+ const costUsd = Math.round((still.costUsd + video(core.PLACEHOLDER_IMAGE).costUsd) * 10_000) / 10_000;
120
+ return { still, video, costUsd, clip: { durationSec, aspectRatio, videoModelId: model.id } };
121
+ }
122
+ const CONTENT_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
123
+ /** Local look images go to fal storage first, so the models can fetch them. */
124
+ async function referenceUrls(fal, looks) {
125
+ const urls = [];
126
+ for (const look of looks.slice(0, 6)) {
127
+ if (/^https?:\/\//.test(look))
128
+ urls.push(look);
129
+ else
130
+ urls.push(await fal.upload({ data: await readFile(look), contentType: CONTENT_TYPES[extname(look).toLowerCase()] ?? "image/png", fileName: basename(look) }));
131
+ }
132
+ return urls;
133
+ }
134
+ async function runJob(core, fal, job, sleep, timeoutMs = 15 * 60_000) {
135
+ const submission = await fal.submit(job.model.endpoint, job.model.mapInput(job.request));
136
+ const started = Date.now();
137
+ for (;;) {
138
+ const s = await fal.status(submission.statusUrl);
139
+ if (s.state === "completed")
140
+ break;
141
+ if (s.state === "failed")
142
+ throw new Error(`fal: ${s.error}`);
143
+ if (Date.now() - started > timeoutMs)
144
+ throw new Error("fal: timed out after 15 minutes");
145
+ await sleep(4000);
146
+ }
147
+ const out = core.extractOutputUrls(await fal.result(submission.responseUrl));
148
+ if (!out[0])
149
+ throw new Error("fal returned no media");
150
+ return out[0].url;
151
+ }
152
+ /** Make the clip with the user's fal key, save it under .appeeky/media/ and record it in the brief. */
153
+ export async function generateLocal(core, root, brief, persona, falKey, options = {}) {
154
+ const fal = core.createFalClient(falKey);
155
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
+ const log = options.log ?? (() => { });
157
+ const refs = await referenceUrls(fal, persona.looks);
158
+ const plan = planLocalClip(core, persona, brief, refs);
159
+ log("still: the persona in the scene");
160
+ const stillUrl = await runJob(core, fal, plan.still, sleep);
161
+ log("video: the persona says the script");
162
+ const videoUrl = await runJob(core, fal, plan.video(stillUrl), sleep);
163
+ const res = await (options.fetcher ?? fetch)(videoUrl);
164
+ if (!res.ok)
165
+ throw new Error(`Could not download the clip: HTTP ${res.status}`);
166
+ await mkdir(mediaDir(root), { recursive: true });
167
+ const file = join(mediaDir(root), `${basename(brief.file, ".yaml")}.mp4`);
168
+ await writeFile(file, new Uint8Array(await res.arrayBuffer()));
169
+ const { file: briefFile, ...rest } = brief;
170
+ const record = { ...rest, status: "generated", clipFile: relative(root, file), videoUrl, costUsd: plan.costUsd, generatedAt: (options.now ?? new Date()).toISOString() };
171
+ await writeFile(briefFile, YAML.stringify(record, { lineWidth: 0 }));
172
+ return { file, videoUrl, stillUrl, costUsd: plan.costUsd };
173
+ }
174
+ //# sourceMappingURL=ugc-local.js.map
package/dist/ugc.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { z } from "zod";
2
+ import type { AppeekyApi } from "./appeeky-api.js";
3
+ /**
4
+ * UGC from Appeeky's creator personas, in the repo (APPEEKY_API_KEY):
5
+ *
6
+ * appeeky/ugc/personas.yaml the creator personas: who they are, their lane and platforms
7
+ * appeeky/ugc/clips.jsonl the clips made with them: script, caption, status, where posted
8
+ * appeeky/ugc/briefs/<name>.yaml a video to make: drafted, reviewed in a pull request, then generated
9
+ *
10
+ * Generating a clip uses Appeeky media credits, so it only runs when a person asks for it.
11
+ */
12
+ export declare const ugcDir: (root: string) => string;
13
+ export declare const briefsDir: (root: string) => string;
14
+ export interface Persona {
15
+ id: string;
16
+ name: string;
17
+ status?: string;
18
+ bio?: string;
19
+ lane?: string;
20
+ platforms?: string[];
21
+ contentPillars?: string[];
22
+ /** Clips need a look (reference images) picked in Appeeky first. */
23
+ ready: boolean;
24
+ }
25
+ export interface Clip {
26
+ id: string;
27
+ persona: string;
28
+ status: string;
29
+ title?: string;
30
+ script?: string;
31
+ caption?: string;
32
+ durationSec?: number;
33
+ aspectRatio?: string;
34
+ scheduledFor?: string;
35
+ socialPostId?: string;
36
+ error?: string;
37
+ createdAt: string;
38
+ }
39
+ interface PersonaRow {
40
+ id: string;
41
+ name: string;
42
+ status?: string;
43
+ bio?: string;
44
+ lane?: string;
45
+ platforms?: string[];
46
+ contentPillars?: string[];
47
+ referenceAssetIds?: string[];
48
+ }
49
+ interface ClipRow {
50
+ id: string;
51
+ personaId: string;
52
+ status: string;
53
+ title?: string;
54
+ script?: string;
55
+ caption?: string;
56
+ durationSec?: number;
57
+ aspectRatio?: string;
58
+ scheduledFor?: string | null;
59
+ socialPostId?: string | null;
60
+ error?: string | null;
61
+ createdAt: string;
62
+ }
63
+ export declare function toPersona(r: PersonaRow): Persona;
64
+ /** Signed media URLs expire within hours, so clips are stored without them. */
65
+ export declare function toClip(r: ClipRow, personas: Persona[]): Clip;
66
+ export declare function fetchUgc(api: AppeekyApi, options?: {
67
+ limit?: number;
68
+ }): Promise<{
69
+ personas: Persona[];
70
+ clips: Clip[];
71
+ }>;
72
+ export declare function saveUgc(root: string, data: {
73
+ personas: Persona[];
74
+ clips: Clip[];
75
+ }): Promise<void>;
76
+ export declare function readPersonas(root: string): Promise<Persona[]>;
77
+ export declare const briefSchema: z.ZodObject<{
78
+ /** A persona name or id from personas.yaml. */
79
+ persona: z.ZodString;
80
+ /** What the creator says, word for word. */
81
+ script: z.ZodString;
82
+ /** Where it is filmed and what is on screen. */
83
+ scene: z.ZodOptional<z.ZodString>;
84
+ caption: z.ZodOptional<z.ZodString>;
85
+ title: z.ZodOptional<z.ZodString>;
86
+ durationSec: z.ZodOptional<z.ZodNumber>;
87
+ aspectRatio: z.ZodOptional<z.ZodEnum<["9:16", "16:9"]>>;
88
+ /** The hypothesis, for whoever reviews the pull request: which hook, for whom, measured how. */
89
+ why: z.ZodOptional<z.ZodString>;
90
+ status: z.ZodDefault<z.ZodEnum<["draft", "generating", "generated"]>>;
91
+ clipId: z.ZodOptional<z.ZodString>;
92
+ credits: z.ZodOptional<z.ZodNumber>;
93
+ generatedAt: z.ZodOptional<z.ZodString>;
94
+ /** Made locally with --local: the file under .appeeky/media/, fal's URL for it and what fal charged. */
95
+ clipFile: z.ZodOptional<z.ZodString>;
96
+ videoUrl: z.ZodOptional<z.ZodString>;
97
+ costUsd: z.ZodOptional<z.ZodNumber>;
98
+ }, "strict", z.ZodTypeAny, {
99
+ status: "draft" | "generating" | "generated";
100
+ persona: string;
101
+ script: string;
102
+ title?: string | undefined;
103
+ why?: string | undefined;
104
+ scene?: string | undefined;
105
+ caption?: string | undefined;
106
+ durationSec?: number | undefined;
107
+ aspectRatio?: "9:16" | "16:9" | undefined;
108
+ clipId?: string | undefined;
109
+ credits?: number | undefined;
110
+ generatedAt?: string | undefined;
111
+ clipFile?: string | undefined;
112
+ videoUrl?: string | undefined;
113
+ costUsd?: number | undefined;
114
+ }, {
115
+ persona: string;
116
+ script: string;
117
+ status?: "draft" | "generating" | "generated" | undefined;
118
+ title?: string | undefined;
119
+ why?: string | undefined;
120
+ scene?: string | undefined;
121
+ caption?: string | undefined;
122
+ durationSec?: number | undefined;
123
+ aspectRatio?: "9:16" | "16:9" | undefined;
124
+ clipId?: string | undefined;
125
+ credits?: number | undefined;
126
+ generatedAt?: string | undefined;
127
+ clipFile?: string | undefined;
128
+ videoUrl?: string | undefined;
129
+ costUsd?: number | undefined;
130
+ }>;
131
+ export type Brief = z.infer<typeof briefSchema>;
132
+ export declare function readBriefs(root: string): Promise<Array<Brief & {
133
+ file: string;
134
+ }>>;
135
+ export declare function findPersona(brief: Brief, personas: Persona[]): Persona | undefined;
136
+ /** What the clip would cost in media credits, without making it. */
137
+ export declare function quoteBrief(api: AppeekyApi, brief: Brief, persona: Persona): Promise<{
138
+ credits: number;
139
+ available: number;
140
+ affordable: boolean;
141
+ }>;
142
+ /** Start rendering the clip (uses media credits) and record it in the brief. */
143
+ export declare function generateBrief(api: AppeekyApi, brief: Brief & {
144
+ file: string;
145
+ }, persona: Persona, now?: Date): Promise<{
146
+ clipId: string;
147
+ credits: number;
148
+ }>;
149
+ export {};
package/dist/ugc.js ADDED
@@ -0,0 +1,111 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { z } from "zod";
6
+ import { SPEC_DIR, SpecError } from "./files.js";
7
+ /**
8
+ * UGC from Appeeky's creator personas, in the repo (APPEEKY_API_KEY):
9
+ *
10
+ * appeeky/ugc/personas.yaml the creator personas: who they are, their lane and platforms
11
+ * appeeky/ugc/clips.jsonl the clips made with them: script, caption, status, where posted
12
+ * appeeky/ugc/briefs/<name>.yaml a video to make: drafted, reviewed in a pull request, then generated
13
+ *
14
+ * Generating a clip uses Appeeky media credits, so it only runs when a person asks for it.
15
+ */
16
+ export const ugcDir = (root) => join(root, SPEC_DIR, "ugc");
17
+ export const briefsDir = (root) => join(ugcDir(root), "briefs");
18
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)));
19
+ export function toPersona(r) {
20
+ return { ...compact({ id: r.id, name: r.name, status: r.status, bio: r.bio, lane: r.lane, platforms: r.platforms, contentPillars: r.contentPillars }), ready: (r.referenceAssetIds?.length ?? 0) > 0 };
21
+ }
22
+ /** Signed media URLs expire within hours, so clips are stored without them. */
23
+ export function toClip(r, personas) {
24
+ return compact({
25
+ id: r.id,
26
+ persona: personas.find((p) => p.id === r.personaId)?.name ?? r.personaId,
27
+ status: r.status,
28
+ title: r.title,
29
+ script: r.script,
30
+ caption: r.caption,
31
+ durationSec: r.durationSec,
32
+ aspectRatio: r.aspectRatio,
33
+ scheduledFor: r.scheduledFor ?? undefined,
34
+ socialPostId: r.socialPostId ?? undefined,
35
+ error: r.error ?? undefined,
36
+ createdAt: r.createdAt,
37
+ });
38
+ }
39
+ export async function fetchUgc(api, options = {}) {
40
+ const personas = ((await api.get("/ugc/personas"))?.personas ?? []).map(toPersona);
41
+ const clips = ((await api.get("/ugc/clips", { limit: String(options.limit ?? 100) }))?.clips ?? []).map((c) => toClip(c, personas));
42
+ return { personas, clips };
43
+ }
44
+ export async function saveUgc(root, data) {
45
+ await mkdir(ugcDir(root), { recursive: true });
46
+ await writeFile(join(ugcDir(root), "personas.yaml"), YAML.stringify({ personas: data.personas }, { lineWidth: 0 }));
47
+ await writeFile(join(ugcDir(root), "clips.jsonl"), data.clips.map((c) => JSON.stringify(c)).join("\n") + (data.clips.length ? "\n" : ""));
48
+ }
49
+ export async function readPersonas(root) {
50
+ const file = join(ugcDir(root), "personas.yaml");
51
+ if (!existsSync(file))
52
+ return [];
53
+ return (YAML.parse(await readFile(file, "utf8"))?.personas ?? []);
54
+ }
55
+ // ── Briefs ──────────────────────────────────────────────────────────────────
56
+ export const briefSchema = z
57
+ .object({
58
+ /** A persona name or id from personas.yaml. */
59
+ persona: z.string().min(1),
60
+ /** What the creator says, word for word. */
61
+ script: z.string().min(1),
62
+ /** Where it is filmed and what is on screen. */
63
+ scene: z.string().optional(),
64
+ caption: z.string().optional(),
65
+ title: z.string().optional(),
66
+ durationSec: z.number().int().positive().optional(),
67
+ aspectRatio: z.enum(["9:16", "16:9"]).optional(),
68
+ /** The hypothesis, for whoever reviews the pull request: which hook, for whom, measured how. */
69
+ why: z.string().optional(),
70
+ status: z.enum(["draft", "generating", "generated"]).default("draft"),
71
+ clipId: z.string().optional(),
72
+ credits: z.number().optional(),
73
+ generatedAt: z.string().optional(),
74
+ /** Made locally with --local: the file under .appeeky/media/, fal's URL for it and what fal charged. */
75
+ clipFile: z.string().optional(),
76
+ videoUrl: z.string().url().optional(),
77
+ costUsd: z.number().optional(),
78
+ })
79
+ .strict();
80
+ export async function readBriefs(root) {
81
+ const dir = briefsDir(root);
82
+ if (!existsSync(dir))
83
+ return [];
84
+ const out = [];
85
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
86
+ const parsed = briefSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
87
+ if (!parsed.success)
88
+ throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
89
+ out.push({ ...parsed.data, file: join(dir, f) });
90
+ }
91
+ return out;
92
+ }
93
+ export function findPersona(brief, personas) {
94
+ const ref = brief.persona.toLowerCase();
95
+ return personas.find((p) => p.id === brief.persona || p.name.toLowerCase() === ref);
96
+ }
97
+ function clipBody(brief) {
98
+ return compact({ script: brief.script, scene: brief.scene, caption: brief.caption, title: brief.title, durationSec: brief.durationSec, aspectRatio: brief.aspectRatio });
99
+ }
100
+ /** What the clip would cost in media credits, without making it. */
101
+ export async function quoteBrief(api, brief, persona) {
102
+ return api.post(`/ugc/personas/${encodeURIComponent(persona.id)}/clips`, { ...clipBody(brief), dryRun: true });
103
+ }
104
+ /** Start rendering the clip (uses media credits) and record it in the brief. */
105
+ export async function generateBrief(api, brief, persona, now = new Date()) {
106
+ const res = await api.post(`/ugc/personas/${encodeURIComponent(persona.id)}/clips`, clipBody(brief));
107
+ const { file, ...rest } = brief;
108
+ await writeFile(file, YAML.stringify({ ...rest, status: "generating", clipId: res.clip.id, credits: res.credits, generatedAt: now.toISOString() }, { lineWidth: 0 }));
109
+ return { clipId: res.clip.id, credits: res.credits };
110
+ }
111
+ //# sourceMappingURL=ugc.js.map
@@ -0,0 +1,70 @@
1
+ import type { Project, StoreId } from "./schema.js";
2
+ /** Where a store app's search rank for a keyword comes from. `null` = not in the results. */
3
+ export interface RankSource {
4
+ readonly label: string;
5
+ /** Ranks for several keywords in one storefront. Missing keys mean "unknown". */
6
+ ranks(store: StoreId, appId: string, country: string, keywords: string[]): Promise<Record<string, number | null>>;
7
+ }
8
+ /** country → keyword → rank */
9
+ export type RankSnapshot = Record<string, Record<string, number | null>>;
10
+ export interface RankBaseline {
11
+ at: string;
12
+ source: string;
13
+ ranks: Partial<Record<StoreId, RankSnapshot>>;
14
+ }
15
+ /** Store ids for rank lookups: numeric App Store id, Google Play package name. */
16
+ export declare function rankAppIds(project: Project): Partial<Record<StoreId, string>>;
17
+ /** Ranks for every target of the given stores; failures leave that storefront out. */
18
+ export declare function snapshotRanks(project: Project, source: RankSource, stores: StoreId[]): Promise<RankBaseline>;
19
+ export interface RankMove {
20
+ country: string;
21
+ keyword: string;
22
+ before: number | null;
23
+ after: number | null;
24
+ }
25
+ export type Verdict = "won" | "lost" | "mixed" | "flat";
26
+ export interface RankEvaluation {
27
+ improved: RankMove[];
28
+ dropped: RankMove[];
29
+ /** Top-20 terms that fell out of the top 20 by 10+ places, or stopped ranking. */
30
+ regressions: RankMove[];
31
+ verdict: Verdict;
32
+ /** Set when ranks did not move and the verdict came from these outcome metrics instead. */
33
+ decidedBy?: string[];
34
+ /** The changed storefronts against the unchanged ones over the same days. */
35
+ control?: import("./control.js").ControlComparison;
36
+ /** Why the verdict differs from the raw rank count, in words. */
37
+ note?: string;
38
+ }
39
+ /**
40
+ * Compare two snapshots of one store. A release "lost" when one top-10 term
41
+ * or two top-20 terms fell away (the rule the co-founder uses to propose a
42
+ * revert), "won" when more terms rose than fell, "mixed" or "flat" otherwise.
43
+ */
44
+ export declare function evaluateRanks(before: RankSnapshot, after: RankSnapshot): RankEvaluation;
45
+ /** App Store ranks from Apple's public search API: no key needed, top 200 results. */
46
+ export declare function itunesRankSource(fetcher?: typeof fetch): RankSource;
47
+ /** Combine sources: the first one that knows a keyword wins. */
48
+ export declare function firstRankSource(...sources: RankSource[]): RankSource;
49
+ /** Store-listing conversion over a date range, e.g. installs / product page visitors. */
50
+ export interface ConversionWindow {
51
+ /** installs ÷ visitors, 0–1 */
52
+ rate: number;
53
+ visitors: number;
54
+ installs: number;
55
+ days: number;
56
+ }
57
+ /** Where conversion numbers come from (App Store Connect analytics, Play Console, a local cache). */
58
+ export interface ConversionSource {
59
+ readonly label: string;
60
+ /** Conversion between two ISO dates (inclusive start, exclusive end); null when there is no data. */
61
+ conversion(store: StoreId, appId: string, from: string, to: string): Promise<ConversionWindow | null>;
62
+ }
63
+ export interface ConversionChange {
64
+ before: ConversionWindow;
65
+ after: ConversionWindow;
66
+ /** Relative change of the rate, e.g. 0.12 = +12%. */
67
+ change: number;
68
+ }
69
+ /** Compare the conversion of the `days` before a push with the `days` after it. */
70
+ export declare function measureConversion(source: ConversionSource, store: StoreId, appId: string, pushedAt: string, days: number): Promise<ConversionChange | null>;
package/dist/verify.js ADDED
@@ -0,0 +1,134 @@
1
+ import { normalize } from "./text.js";
2
+ /** Store ids for rank lookups: numeric App Store id, Google Play package name. */
3
+ export function rankAppIds(project) {
4
+ const ids = {};
5
+ const asc = project.spec.stores.appStore?.appId;
6
+ if (asc && /^\d+$/.test(asc))
7
+ ids["app-store"] = asc;
8
+ if (project.spec.stores.googlePlay)
9
+ ids["google-play"] = project.spec.stores.googlePlay.packageName;
10
+ return ids;
11
+ }
12
+ /** Ranks for every target of the given stores; failures leave that storefront out. */
13
+ export async function snapshotRanks(project, source, stores) {
14
+ const ids = rankAppIds(project);
15
+ const baseline = { at: new Date().toISOString(), source: source.label, ranks: {} };
16
+ for (const store of stores) {
17
+ const appId = ids[store];
18
+ if (!appId)
19
+ continue;
20
+ const snap = {};
21
+ for (const [country, targets] of Object.entries(project.spec.targets)) {
22
+ const keywords = [...new Set(targets.map((k) => normalize(k).trim()))];
23
+ if (keywords.length === 0)
24
+ continue;
25
+ try {
26
+ snap[country] = await source.ranks(store, appId, country, keywords);
27
+ }
28
+ catch {
29
+ // A storefront that cannot be read now is simply not part of the baseline.
30
+ }
31
+ }
32
+ if (Object.keys(snap).length > 0)
33
+ baseline.ranks[store] = snap;
34
+ }
35
+ return baseline;
36
+ }
37
+ /** A move only counts when it is at least this many places. */
38
+ const MIN_MOVE = 3;
39
+ /** Same thresholds as the co-founder's ranking-term guard and auto-revert. */
40
+ const TOP = 20;
41
+ const DROP_PLACES = 10;
42
+ /** Not ranked counts as just past the 200 results the stores return, so "out → out" is no move. */
43
+ const UNRANKED = 201;
44
+ const asRank = (r) => (r === null || r === undefined ? UNRANKED : r);
45
+ /**
46
+ * Compare two snapshots of one store. A release "lost" when one top-10 term
47
+ * or two top-20 terms fell away (the rule the co-founder uses to propose a
48
+ * revert), "won" when more terms rose than fell, "mixed" or "flat" otherwise.
49
+ */
50
+ export function evaluateRanks(before, after) {
51
+ const improved = [];
52
+ const dropped = [];
53
+ const regressions = [];
54
+ for (const [country, ranks] of Object.entries(before)) {
55
+ for (const [keyword, b] of Object.entries(ranks)) {
56
+ if (!(keyword in (after[country] ?? {})))
57
+ continue;
58
+ const a = after[country][keyword] ?? null;
59
+ const move = { country, keyword, before: b, after: a };
60
+ const from = asRank(b);
61
+ const to = asRank(a);
62
+ if (to + MIN_MOVE <= from)
63
+ improved.push(move);
64
+ else if (from + MIN_MOVE <= to)
65
+ dropped.push(move);
66
+ if (b !== null && b <= TOP && (a === null || (a > TOP && a - b >= DROP_PLACES)))
67
+ regressions.push(move);
68
+ }
69
+ }
70
+ const lost = regressions.length >= 2 || regressions.some((r) => (r.before ?? UNRANKED) <= 10);
71
+ const verdict = lost
72
+ ? "lost"
73
+ : improved.length > dropped.length
74
+ ? "won"
75
+ : improved.length === 0 && dropped.length === 0
76
+ ? "flat"
77
+ : "mixed";
78
+ return { improved, dropped, regressions, verdict };
79
+ }
80
+ /** App Store ranks from Apple's public search API: no key needed, top 200 results. */
81
+ export function itunesRankSource(fetcher = fetch) {
82
+ return {
83
+ label: "App Store search",
84
+ async ranks(store, appId, country, keywords) {
85
+ if (store !== "app-store")
86
+ return {};
87
+ const out = {};
88
+ for (const keyword of keywords) {
89
+ const url = `https://itunes.apple.com/search?media=software&entity=software&limit=200&country=${encodeURIComponent(country)}&term=${encodeURIComponent(keyword)}`;
90
+ const res = await fetcher(url, { headers: { "User-Agent": "appeeky-spec" } });
91
+ if (!res.ok)
92
+ continue;
93
+ const json = (await res.json());
94
+ const index = (json.results ?? []).findIndex((r) => String(r.trackId) === appId);
95
+ out[keyword] = index >= 0 ? index + 1 : null;
96
+ }
97
+ return out;
98
+ },
99
+ };
100
+ }
101
+ /** Combine sources: the first one that knows a keyword wins. */
102
+ export function firstRankSource(...sources) {
103
+ return {
104
+ label: sources.map((s) => s.label).join(" + "),
105
+ async ranks(store, appId, country, keywords) {
106
+ const out = {};
107
+ let left = keywords;
108
+ for (const s of sources) {
109
+ if (left.length === 0)
110
+ break;
111
+ try {
112
+ Object.assign(out, await s.ranks(store, appId, country, left));
113
+ }
114
+ catch {
115
+ // try the next source
116
+ }
117
+ left = keywords.filter((k) => !(k in out));
118
+ }
119
+ return out;
120
+ },
121
+ };
122
+ }
123
+ /** Compare the conversion of the `days` before a push with the `days` after it. */
124
+ export async function measureConversion(source, store, appId, pushedAt, days) {
125
+ const t = Date.parse(pushedAt);
126
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
127
+ const span = Math.max(1, days) * 86_400_000;
128
+ const before = await source.conversion(store, appId, iso(t - span), iso(t));
129
+ const after = await source.conversion(store, appId, iso(t + 86_400_000), iso(t + 86_400_000 + span));
130
+ if (!before || !after || before.visitors === 0 || after.visitors === 0)
131
+ return null;
132
+ return { before, after, change: before.rate > 0 ? after.rate / before.rate - 1 : 0 };
133
+ }
134
+ //# sourceMappingURL=verify.js.map