@heyramzi/social 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramzi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @heyramzi/social
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The heuristics for scripts and posts, written once. CutKit's script check and UpSys's Jev loop
4
+ are different apps with their own copy, weights and contracts, and both import their rules from
5
+ here so a rule changes in one place.
6
+
7
+ | Entry | What it holds | Read by |
8
+ | --- | --- | --- |
9
+ | `./story` | Checks with no model: `openerFault` (greeting, hidden point, scene first), `hedgeScan`, `flatRun`, `HOOK_MAX_WORDS` | CutKit's offline lint, `./shorts` |
10
+ | `./questions` | `storyQuestions({ field, piece, reader })`: the story questions a judge model answers, in the surface's own words | CutKit's check, Jev's `short`, `opening`, `post` and `x` surfaces |
11
+ | `./hooks` | One regex per hook-library shape, and the banned shape | `./shorts` |
12
+ | `./shorts` | `matchHook` and `auditShorts`, the skeleton of a Short | `heyramzi-slop --register=shorts` in `@heyramzi/lint` |
13
+
14
+ Fix copy never lives here. A question in the bank says what to ask; the app says what to tell
15
+ the person when the answer drags the grade down.
16
+
17
+ ## Adding a question
18
+
19
+ Add it to `storyQuestions` with its `source`. It earns a place in a surface once
20
+ `pnpm jev train <surface>` in UpSys keeps it, and in CutKit's check once it has done that on
21
+ `short`. The `script` wording CutKit already ships is pinned in `questions.test.mjs`, because the
22
+ judge's answers move when a word does.
23
+
24
+ ## Calibration
25
+
26
+ A rule here is measured against the CutKit corpus before an app gates on it. On 14 Oct 2026,
27
+ over 5,089 punctuated Short transcripts from 23 creators, a sentence-rhythm rule (4 sentences in
28
+ a row at one length) fired on 30.5% of winners and 27.9% of the rest, so it isn't here. `flatRun`
29
+ flags 57% of winners and 60% of the rest: CutKit shows it as a nudge, never a fail.
30
+
31
+ ```
32
+ pnpm test
33
+ ```
package/hooks.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface HookShape {
2
+ n: number;
3
+ family: string;
4
+ test: RegExp;
5
+ second?: boolean;
6
+ maxWords?: number;
7
+ alone?: boolean;
8
+ }
9
+ export declare const hookShapes: HookShape[];
10
+ export declare const bannedHook: RegExp;
package/hooks.js ADDED
@@ -0,0 +1,114 @@
1
+ /** One regex per hook-library shape, keyed by its number. The prose lives in shorts-script. */
2
+ const N = "(?:\\d+|two|three|four|five|six|seven|eight|nine|ten|twelve)";
3
+
4
+ export const hookShapes = [
5
+ { n: 1, family: "Promise", test: /^here(?:'s| is) the only\b/i },
6
+ { n: 2, family: "Promise", test: /^(?:here(?:'s| is)|this is) how (?:to|you|i)\b/i },
7
+ { n: 3, family: "Promise", test: new RegExp(`^here are ${N}\\b`, "i") },
8
+ {
9
+ n: 4,
10
+ family: "Promise",
11
+ second: true,
12
+ test: /\bi (?:use|built|made) (?:a|an|the) [\w ]{1,24}(?:i call|called)\b/i,
13
+ },
14
+ { n: 5, family: "Diagnosis", test: /^the (?:real )?reason (?:your|you)\b/i },
15
+ { n: 6, family: "Diagnosis", test: /^this is the most\b/i },
16
+ { n: 7, family: "Diagnosis", test: /\bthe fastest way to\b.*\bthe fastest way to\b/i },
17
+ {
18
+ n: 8,
19
+ family: "Received-wisdom break",
20
+ test: /^(?:everyone|everybody|people)(?: will| keep)? (?:tells?|telling|says?|saying) you\b|, bro[.!?]?$/i,
21
+ },
22
+ { n: 9, family: "Received-wisdom break", test: /^you (?:do not|don't) need\b/i },
23
+ { n: 10, family: "Received-wisdom break", test: /^every \w+ (?:is|are)\b/i },
24
+ {
25
+ n: 11,
26
+ family: "Received-wisdom break",
27
+ test: /^[^.?!,]{2,50} or [^.?!,]{2,50}[.?]$/i,
28
+ maxWords: 10,
29
+ },
30
+ {
31
+ n: 12,
32
+ family: "In medias res",
33
+ test: /^(?:can't|couldn't|just|still|got|lost|spent|found|watched)\b/i,
34
+ },
35
+ { n: 13, family: "In medias res", test: /^i(?:'m| am) (?:processing|realizing|still)\b/i },
36
+ { n: 14, family: "In medias res", test: /^i regret\b/i },
37
+ { n: 15, family: "Tribe", test: /^if you want to\b.*\bbut\b/i },
38
+ {
39
+ n: 17,
40
+ family: "Tribe",
41
+ test: /^if you(?:'re| are) [^,]{2,40}, you (?:gotta|have to|need to|should|must)\b/i,
42
+ },
43
+ {
44
+ n: 18,
45
+ family: "Tribe",
46
+ test: /^there are (?:fully grown |grown )?\w+(?: \w+)? (?:out there|who)\b/i,
47
+ },
48
+ {
49
+ n: 19,
50
+ family: "Artefact",
51
+ test: /^(?:so )?i (?:saw|got|found) this (?:comment|post|email|message|dm|video|review)\b/i,
52
+ },
53
+ { n: 20, family: "Artefact", test: /^(?:open|pull up|go to) (?:your|the)\b/i },
54
+ { n: 21, family: "Artefact", test: /^pro [\w ]{1,24} tip\b/i },
55
+ {
56
+ n: 22,
57
+ family: "Number",
58
+ test: new RegExp(`^i (?:spent|tested|tried|ran|used|asked) (?:the last |over )?${N}\\b`, "i"),
59
+ },
60
+ { n: 23, family: "Number", test: /^\d[\d,.]*%?\s.*\bso\b/i },
61
+ { n: 24, family: "Number", test: new RegExp(`^${N} [a-z]+`, "i") },
62
+ {
63
+ n: 25,
64
+ family: "Number",
65
+ test: /^[\w'-]+(?: [\w'-]+)?, [\w'-]+(?: [\w'-]+)?,? (?:and )?[\w'-]+(?: [\w'-]+)?\.$/i,
66
+ maxWords: 8,
67
+ },
68
+ { n: 26, family: "Number", test: /^every \w+(?: \w+)? (?:does|do|has) this\b/i },
69
+ {
70
+ n: 27,
71
+ family: "Question",
72
+ test: /^(?:(?:do|does|is|are|can|will|should|did) [^?]{3,80}|what happens when [^?]{3,80})\?$/i,
73
+ },
74
+ { n: 28, family: "Question", test: /^(?:you know|do you know) (?:who|what|why|how)\b/i },
75
+ { n: 29, family: "Confession", test: /^i have a confession\b/i },
76
+ {
77
+ n: 31,
78
+ family: "News",
79
+ test: /^(?:breaking news\b|[\w.\- ]{1,30} just (?:killed|replaced|ended|dropped|changed|released|launched|did it again)\b)/i,
80
+ },
81
+ { n: 32, family: "News", test: /^this is the (?:one|only|new) \w+(?: \w+)? you need\b/i },
82
+ {
83
+ n: 33,
84
+ family: "News",
85
+ test: /^this (?:has (?:got )?to be|might be|is) the (?:craziest|best|wildest|biggest)\b/i,
86
+ },
87
+ {
88
+ n: 34,
89
+ family: "Contradiction",
90
+ test: /^(?:i don't think there's a (?:more|better)\b|[\w.\- ]{2,30} (?:lies|fails|breaks) more than you think\b)/i,
91
+ },
92
+ {
93
+ n: 35,
94
+ family: "Contradiction",
95
+ test: /^most people (?:don't|do not) (?:know|realize|realise)\b/i,
96
+ },
97
+ {
98
+ n: 36,
99
+ family: "Promise",
100
+ test: new RegExp(`^(?:turn on|add|type|use) (?:these|this) (?:${N} )?\\w+`, "i"),
101
+ },
102
+ { n: 37, family: "Promise", test: /^how (?:an?|the) [\w'’ -]{2,40} would\b/i },
103
+ {
104
+ n: 38,
105
+ family: "News",
106
+ test: /^[\w.$'’ -]{2,40} just (?:turned down|said no to|walked away from|rejected)\b/i,
107
+ },
108
+ { n: 39, family: "Diagnosis", test: /^the difference between an? [\w -]{1,30} and an? \w+/i },
109
+ // Shape 16 qualifies the audience and opens no gap, so it only counts beside another shape.
110
+ { n: 16, family: "Tribe", alone: false, test: /^if you\b/i },
111
+ ];
112
+
113
+ // Shape 30: our worst post ran 2 views on it.
114
+ export const bannedHook = /^(?:when i (?:first )?started|i used to think|one year ago,? i)\b/i;
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@heyramzi/social",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "The social-media heuristics CutKit and UpSys share: hook shapes, the Shorts audit, the story checks with no model in them, and the story question bank a judge model answers",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/heyramzi/vibe-kit.git",
9
+ "directory": "packages/social"
10
+ },
11
+ "files": [
12
+ "hooks.js",
13
+ "hooks.d.ts",
14
+ "questions.js",
15
+ "questions.d.ts",
16
+ "shorts.js",
17
+ "shorts.d.ts",
18
+ "story.js",
19
+ "story.d.ts",
20
+ "package.json"
21
+ ],
22
+ "type": "module",
23
+ "imports": {
24
+ "#social/*": "./*"
25
+ },
26
+ "exports": {
27
+ "./hooks": {
28
+ "types": "./hooks.d.ts",
29
+ "default": "./hooks.js"
30
+ },
31
+ "./questions": {
32
+ "types": "./questions.d.ts",
33
+ "default": "./questions.js"
34
+ },
35
+ "./shorts": {
36
+ "types": "./shorts.d.ts",
37
+ "default": "./shorts.js"
38
+ },
39
+ "./story": {
40
+ "types": "./story.d.ts",
41
+ "default": "./story.js"
42
+ }
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test \"*.test.mjs\""
49
+ }
50
+ }
package/questions.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type StoryQuestion = { instructions: string; source: string } & (
2
+ | { type: "noul"; criteria?: undefined }
3
+ /** Ordered low to high. */
4
+ | { type: "score"; criteria: string[] }
5
+ );
6
+
7
+ export type StoryQuestionId =
8
+ | "opening_speed"
9
+ | "contrast"
10
+ | "rehook"
11
+ | "stakes"
12
+ | "intro_order"
13
+ | "visual_first"
14
+ | "head_fake"
15
+ | "villain"
16
+ | "atomic_share";
17
+
18
+ export declare function storyQuestions(voice: {
19
+ field: string;
20
+ piece: string;
21
+ reader: string;
22
+ }): Record<StoryQuestionId, StoryQuestion>;
package/questions.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The story questions a judge model answers, written once and asked in each surface's words.
3
+ * Which ones a surface asks, its weights and its fix copy stay in the app.
4
+ */
5
+
6
+ const KALLAWAY = "Kallaway, How to Master Storytelling (youtube.com/watch?v=WluuZB_ck9Q)";
7
+
8
+ /**
9
+ * @param {{ field: string, piece: string, reader: string }} voice
10
+ * `field` is the state key the judge reads ("script"), `piece` what it is ("video"),
11
+ * `reader` who meets it ("viewer").
12
+ */
13
+ export function storyQuestions({ field, piece, reader }) {
14
+ const f = `\`${field}\``;
15
+ const keep = reader === "viewer" ? "keep watching" : "keep reading";
16
+ const once = reader === "viewer" ? "one watch" : "one read";
17
+ return {
18
+ opening_speed: {
19
+ type: "score",
20
+ instructions: `How fast does the ${f} say what the ${piece} is about and what the ${reader} gets? Judge the first two sentences.`,
21
+ criteria: [
22
+ `After more than two sentences the ${reader} still doesn't know the subject or the payoff.`,
23
+ "The subject shows up in the first two sentences but the payoff stays unclear.",
24
+ "The subject is clear in the first sentence, the payoff by the second.",
25
+ "One short sentence of about ten words names both the subject and the outcome.",
26
+ ],
27
+ source: "Kallaway interrupt theory: subject, stakes, speed, clarity",
28
+ },
29
+ contrast: {
30
+ type: "noul",
31
+ instructions: `In its first three sentences, does the ${f} set what the ${reader} already believes or does against something different, stated ('most people do A, this does B') or implied ('this is 8 times faster')?`,
32
+ source: "Kallaway headfake; vidIQ: statement adds contrast",
33
+ },
34
+ rehook: {
35
+ type: "noul",
36
+ instructions: `Halfway through, does the ${f} add a new question, a higher stake, a revealed mechanism, or bring back the problem from the opening and show that a better answer is still ahead, giving the ${reader} a new reason to ${keep}? A sentence that only announces what's next ('this is where it gets crazy') doesn't count.`,
37
+ source: `Kallaway rehook and rebuy; ${KALLAWAY}`,
38
+ },
39
+ stakes: {
40
+ type: "noul",
41
+ instructions: `Does the ${f} name who it's about, what they could lose, and why now?`,
42
+ source: "Kallaway addiction loop: a character, something at risk, urgency",
43
+ },
44
+ intro_order: {
45
+ type: "noul",
46
+ instructions: `In its first two sentences, does the ${f} say what is happening and why it matters to the ${reader} before it says who, when or where? Opening on a time and place ('Last month I was at a conference in Miami') or on the author's credentials counts as no.`,
47
+ source: `Kallaway W order: what and why, then who and how, then when and where; ${KALLAWAY}`,
48
+ },
49
+ visual_first: {
50
+ type: "noul",
51
+ instructions: `Does the first sentence of the ${f} name something the ${reader} can picture at once, such as a physical object, a screen, a result or a number, rather than only an idea?`,
52
+ source: `Kallaway: visual hooks beat audio-only hooks, show while you tell; ${KALLAWAY}`,
53
+ },
54
+ head_fake: {
55
+ type: "noul",
56
+ instructions: `Does the ${f} lead the ${reader} toward one answer and then give a different one that its earlier details make sense of? A twist with no clues before it counts as no.`,
57
+ source: `Kallaway addiction loop: prediction error, the head fake; ${KALLAWAY}`,
58
+ },
59
+ villain: {
60
+ type: "noul",
61
+ instructions: `Does the ${f} set the ${reader} against a named opponent: a person, company, group, tool, habit or belief it paints as the problem?`,
62
+ source: `Kallaway: a missing villain; ${KALLAWAY}`,
63
+ },
64
+ atomic_share: {
65
+ type: "noul",
66
+ instructions: `After ${once}, could the ${reader} retell the main point of the ${f} to a friend in one sentence of under 10 words?`,
67
+ source: `Kallaway atomic shareability, the Paul Revere effect; ${KALLAWAY}`,
68
+ },
69
+ };
70
+ }
package/shorts.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type HookMatch =
2
+ | { shape: number; banned: true }
3
+ | { vague: true }
4
+ | { shape: number; family: string; alone: boolean };
5
+ export declare function matchHook(first: string, second?: string): HookMatch | null;
6
+ export declare function auditShorts(text: string): {
7
+ hook: HookMatch | null;
8
+ findings: { kind: "shorts"; id: string; detail: string }[];
9
+ };
package/shorts.js ADDED
@@ -0,0 +1,111 @@
1
+ import { bannedHook, hookShapes } from "#social/hooks.js";
2
+ import { HOOK_MAX_WORDS, openerFault } from "#social/story.js";
3
+
4
+ const SENTENCE = /[^.!?]+(?:[.!?]+["'”’)]*|$)/g;
5
+ const COUNT =
6
+ /\b(?:no\.? ?\d|number (?:one|two|three|four|five|\d)|step (?:one|two|three|four|\d)|(?:the )?first|second|third|fourth)\b/gi;
7
+ const REPLY_CTA = /\b(?:comment|dm me|type|reply)\b/i;
8
+
9
+ /** The spoken part of a note: header lines and everything under a `---` divider are notes to self. */
10
+ function spoken(text) {
11
+ const body = text.split(/^-{3,}\s*$/m)[0];
12
+ return body
13
+ .split("\n")
14
+ .filter((line) => /[.!?]["'”’)]*\s*$/.test(line.trim()))
15
+ .join(" ")
16
+ .trim();
17
+ }
18
+
19
+ const words = (s) => s.split(/\s+/).filter(Boolean).length;
20
+
21
+ const STOP = new Set(
22
+ "that this with they them their there then than what when where which your yours from have been were will would could should about into just like more most some such very also only even ever never always because while these those other every thing things people".split(
23
+ " ",
24
+ ),
25
+ );
26
+ const content = (s) =>
27
+ (s.toLowerCase().match(/[a-z']{4,}/g) ?? [])
28
+ .map((w) => w.replace(/s$/, ""))
29
+ .filter((w) => !STOP.has(w));
30
+ /** Two short sentences that swap 2 nearby words around a shared frame: "good team, bad plan. good plans, bad teams." */
31
+ function chiasmus(sentences) {
32
+ for (let i = 0; i + 1 < sentences.length; i++) {
33
+ const short = (s) => s.split(/\s+/).length <= 18;
34
+ if (!short(sentences[i]) || !short(sentences[i + 1])) continue;
35
+ const a = content(sentences[i]),
36
+ b = content(sentences[i + 1]);
37
+ const once = (arr, w) => arr.indexOf(w) === arr.lastIndexOf(w);
38
+ for (let x = 0; x < a.length; x++)
39
+ for (let y = x + 1; y < a.length; y++) {
40
+ const p = a[x],
41
+ q = a[y];
42
+ if (p === q || y - x > 2) continue;
43
+ const bp = b.indexOf(p),
44
+ bq = b.indexOf(q);
45
+ if (
46
+ bp !== -1 &&
47
+ bq !== -1 &&
48
+ bq < bp &&
49
+ bp - bq <= 2 &&
50
+ once(a, p) &&
51
+ once(a, q) &&
52
+ once(b, p) &&
53
+ once(b, q)
54
+ ) {
55
+ const kept = a.filter((w) => w !== p && w !== q && b.includes(w));
56
+ const ordered = kept.every((w, k) => k === 0 || b.indexOf(kept[k - 1]) < b.indexOf(w));
57
+ if (kept.length >= 1 && ordered) return `${sentences[i]} ${sentences[i + 1]}`;
58
+ }
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** Which library shape the opening runs on, or why it runs on none. */
65
+ export function matchHook(first, second = "") {
66
+ const hook = first.trim();
67
+ if (bannedHook.test(hook)) return { shape: 30, banned: true };
68
+ const fault = openerFault(hook);
69
+ if (fault === "opaque" || fault === "scene") return { vague: true };
70
+ for (const s of hookShapes) {
71
+ const target = s.second ? second.trim() : hook;
72
+ if (s.maxWords && words(target) > s.maxWords) continue;
73
+ if (s.test.test(target)) return { shape: s.n, family: s.family, alone: s.alone === false };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** The hook and the skeleton of a Short, per the shorts-script skill. */
79
+ export function auditShorts(text) {
80
+ const findings = [];
81
+ const sentences = (spoken(text).match(SENTENCE) ?? []).map((s) => s.trim()).filter(Boolean);
82
+ if (sentences.length === 0) return { hook: null, findings };
83
+ const [first, second] = sentences;
84
+ const push = (id, detail) => findings.push({ kind: "shorts", id, detail });
85
+
86
+ const hook = matchHook(first, second);
87
+ if (!hook) push("hook-shape", `"${first}" matches no shape in the hook library`);
88
+ else if (hook.vague) push("hook-vague", `"${first}" hides the plot or opens on a time and place`);
89
+ else if (hook.banned) push("hook-banned", `"${first}" is shape 30, banned`);
90
+ else if (hook.alone)
91
+ push("hook-alone", `"${first}" is shape 16, which qualifies the audience and opens no gap`);
92
+ if (words(first) > HOOK_MAX_WORDS)
93
+ push("hook-length", `the hook is ${words(first)} words, the window takes ${HOOK_MAX_WORDS}`);
94
+
95
+ const clefts = sentences.filter(
96
+ (s) => /^What [^?]{3,60}?\b(?:is|are|was|were)\s/.test(s) && !s.endsWith("?"),
97
+ );
98
+ if (clefts.length > 1)
99
+ push("what-cleft", `${clefts.length} "What X is Y" sentences; 1 per script`);
100
+ const mirror = chiasmus(sentences);
101
+ if (mirror) push("chiasmus", `mirrored close: "${mirror}"`);
102
+
103
+ const counted = (sentences.slice(1).join(" ").match(COUNT) ?? []).length;
104
+ if (counted < 2)
105
+ push("uncounted-steps", `${counted} count markers in the body; name the steps out loud`);
106
+
107
+ const close = sentences.slice(-2).join(" ");
108
+ if (!REPLY_CTA.test(close)) push("cta-reply", "the close carries no reply-trigger CTA");
109
+
110
+ return { hook, findings };
111
+ }
package/story.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export declare const HOOK_MAX_WORDS: number;
2
+ export type OpenerFault = "greeting" | "opaque" | "scene";
3
+ export declare function openerFault(text: string): OpenerFault | null;
4
+ export declare function wordCount(text: string): number;
5
+ export declare function firstSentence(text: string): string;
6
+ export declare const HEDGES: string[];
7
+ export declare function hedgeScan(text: string): {
8
+ pass: boolean;
9
+ dense: boolean;
10
+ total: number;
11
+ found: string[];
12
+ };
13
+ export declare const FLAT_RUN_WORDS: number;
14
+ export declare const FLAT_RUN_MIN_WORDS: number;
15
+ export declare const WORDS_PER_SECOND: number;
16
+ export declare function flatRun(text: string): { words: number; seconds: number; flagged: boolean };
package/story.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The story checks that need no model: how a script or post opens, hedges and turns.
3
+ * CutKit and the Shorts gate both read these. Fix copy stays in each app.
4
+ */
5
+
6
+ /** The window a hook gets: past this the first sentence is still talking when the swipe comes. */
7
+ export const HOOK_MAX_WORDS = 15;
8
+
9
+ // WHY: every short-form source we read names a greeting opener as the top failure.
10
+ const GREETING =
11
+ /^(hey|hi|hello|welcome|so basically|in this video|today we're going to|today i'm going to|let me tell you)\b/iu;
12
+ // Kallaway's classroom: hiding the plot loses the swipe.
13
+ const OPAQUE =
14
+ /^(?:wait (?:till|until) you see|you(?:['’]re| are) (?:never |not )?(?:going to|gonna) believe)/iu;
15
+ // Kallaway's W order: what and why first, who and how next, when and where last. A time and
16
+ // place in the first words is the icing served before the cake.
17
+ const SCENE =
18
+ /^(?:(?:last|this) (?:week|month|year|summer|winter),? i was\b|it was an? (?:[\w,]+ ){0,3}(?:night|day|morning|evening)\b)/iu;
19
+
20
+ /** The rule an opening breaks: a greeting, a hidden point or a scene set first. Null when clean. */
21
+ export function openerFault(text) {
22
+ const opening = text.trim();
23
+ if (GREETING.test(opening)) return "greeting";
24
+ if (OPAQUE.test(opening)) return "opaque";
25
+ if (SCENE.test(opening)) return "scene";
26
+ return null;
27
+ }
28
+
29
+ export const wordCount = (text) => text.split(/\s+/u).filter(Boolean).length;
30
+
31
+ export function firstSentence(text) {
32
+ return text.trim().split(/(?<=[.!?])(?:\s|$)|\n+/u, 1)[0] ?? "";
33
+ }
34
+
35
+ // WHY no "if": 6 of the 10 viral scripts in CutKit's REAL_SHORTS say "if you", 2 in their first line.
36
+ export const HEDGES = [
37
+ "maybe",
38
+ "might",
39
+ "could",
40
+ "probably",
41
+ "perhaps",
42
+ "possibly",
43
+ "hopefully",
44
+ "kind of",
45
+ "sort of",
46
+ "i think",
47
+ "i guess",
48
+ ];
49
+
50
+ const hedgePattern = (hedge, flags) => new RegExp(`\\b${hedge.replaceAll(" ", "\\s+")}\\b`, flags);
51
+
52
+ /**
53
+ * Honest uncertainty is fine, so only a hedge in the first 2 sentences, or more than 1 per
54
+ * 100 words, fails. `found` names the hedges to cut.
55
+ */
56
+ export function hedgeScan(text) {
57
+ const words = wordCount(text);
58
+ const early = text
59
+ .trim()
60
+ .split(/(?<=[.!?])(?:\s|$)/u)
61
+ .slice(0, 2)
62
+ .join(" ");
63
+ const earlyFound = HEDGES.filter((hedge) => hedgePattern(hedge, "iu").test(early));
64
+ const total = HEDGES.reduce(
65
+ (sum, hedge) => sum + (text.match(hedgePattern(hedge, "igu")) ?? []).length,
66
+ 0,
67
+ );
68
+ const dense = total > Math.max(1, words / 100);
69
+ const found = earlyFound.length
70
+ ? earlyFound
71
+ : dense
72
+ ? HEDGES.filter((hedge) => hedgePattern(hedge, "iu").test(text))
73
+ : [];
74
+ return { pass: earlyFound.length === 0 && !dense, dense, total, found };
75
+ }
76
+
77
+ // The words that turn a story: a contrast, or a list stepping on.
78
+ const TURNS = new Set([
79
+ "but",
80
+ "actually",
81
+ "except",
82
+ "however",
83
+ "yet",
84
+ "though",
85
+ "instead",
86
+ "until",
87
+ "whereas",
88
+ // WHY: a list moves on at each step, and 5 of 10 viral scripts in REAL_SHORTS turn only that way.
89
+ "next",
90
+ "first",
91
+ "second",
92
+ "third",
93
+ "finally",
94
+ ]);
95
+ /** A stretch longer than this with no turn in it drifts. South Park's rule: but or therefore, never and then. */
96
+ export const FLAT_RUN_WORDS = 70;
97
+ /** Below this length a whole script is one breath, so a flat run says nothing. */
98
+ export const FLAT_RUN_MIN_WORDS = 120;
99
+ export const WORDS_PER_SECOND = 2.5;
100
+
101
+ /** The longest run of words with no turn, a question or a numbered step in it. */
102
+ export function flatRun(text) {
103
+ const raw = text.split(/\s+/u).filter(Boolean);
104
+ const words = raw.map((word) => word.toLowerCase().replace(/[^a-z]/gu, ""));
105
+ let run = 0;
106
+ let worst = 0;
107
+ for (let index = 0; index < words.length; index += 1) {
108
+ // WHY the pair: "turns out" turns on two words, so one token never matches it.
109
+ if (words[index] === "turns" && words[index + 1] === "out") {
110
+ run = 0;
111
+ index += 1;
112
+ continue;
113
+ }
114
+ if (
115
+ TURNS.has(words[index] ?? "") ||
116
+ raw[index]?.includes("?") ||
117
+ /^\d+[.)]$/u.test(raw[index] ?? "")
118
+ )
119
+ run = 0;
120
+ else {
121
+ run += 1;
122
+ worst = Math.max(worst, run);
123
+ }
124
+ }
125
+ const flagged = raw.length > FLAT_RUN_MIN_WORDS && worst > FLAT_RUN_WORDS;
126
+ return { words: worst, seconds: Math.round(worst / WORDS_PER_SECOND), flagged };
127
+ }