agentic-ads-engine 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/README.md +102 -13
- package/bin/agentic-ads +14 -1
- package/bin/agentic-ads-mcp +3 -1
- package/package.json +4 -1
- package/src/formats/index.js +3 -1
- package/src/formats/library.json +24 -0
- package/src/hooks/index.js +26 -3
- package/src/hooks/library.json +42 -6
- package/src/http.js +104 -0
- package/src/index.js +14 -4
- package/src/qa/index.js +16 -0
- package/src/qa/rules.json +28 -0
package/README.md
CHANGED
|
@@ -2,25 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
Offline tools for ad drafts. Node 20 or later. Plain JavaScript ESM with JSDoc, no build step, and zero runtime dependencies.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
Run from this checkout:
|
|
5
|
+
## Install
|
|
8
6
|
|
|
9
7
|
```sh
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
node bin/agentic-ads lint --copy 'Browse available listings'
|
|
13
|
-
node bin/agentic-ads plan --site demo --goal discovery --dry-run
|
|
14
|
-
node test/e2e.mjs
|
|
8
|
+
npm install agentic-ads-engine # library and CLI in a project
|
|
9
|
+
npm install -g agentic-ads-engine # the agentic-ads and agentic-ads-mcp commands
|
|
15
10
|
```
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
## Quick start
|
|
18
13
|
|
|
19
14
|
```sh
|
|
20
|
-
|
|
21
|
-
npx agentic-ads
|
|
15
|
+
npx agentic-ads list-templates
|
|
16
|
+
npx agentic-ads hooks
|
|
17
|
+
npx agentic-ads lint --copy 'Browse available listings'
|
|
18
|
+
npx agentic-ads plan --site demo --goal discovery --dry-run
|
|
19
|
+
npx agentic-ads serve --port 8787 # HTTP API, loopback only unless AGENTIC_ADS_TOKEN is set
|
|
20
|
+
npx agentic-ads-mcp # MCP server over stdio
|
|
22
21
|
```
|
|
23
22
|
|
|
23
|
+
From a git checkout, use `node bin/agentic-ads ...` and run `node test/e2e.mjs` for the tests.
|
|
24
|
+
|
|
24
25
|
CLI output is JSON. Exit codes: `0` for a result, `1` for invalid input, and `2` for blocked copy.
|
|
25
26
|
`lint` requires `--copy`. It accepts `--category housing|none`; the default is `housing`.
|
|
26
27
|
`plan` accepts `--site`, `--goal`, `--template`, `--ratio`, `--copy`, and `--category`.
|
|
@@ -99,10 +100,10 @@ News framing, staging, and urgency also require review. Conservative checks can
|
|
|
99
100
|
The package does not inspect actual audience settings or implement current platform policy in full.
|
|
100
101
|
|
|
101
102
|
Keep private data out of inputs. Contact-pattern checks are incomplete; they cannot reliably detect names, addresses, or secrets.
|
|
102
|
-
The CLI prints supplied copy in plans. No telemetry, account integration,
|
|
103
|
+
The CLI prints supplied copy in plans. No telemetry, account integration, or outbound network calls are included.
|
|
103
104
|
No source assets or competitor copy are bundled. Research informs the formats and disclosure needs only.
|
|
104
105
|
|
|
105
|
-
The E2E script spawns
|
|
106
|
+
The E2E script spawns the CLI, MCP server, HTTP server, and a library integration process.
|
|
106
107
|
It records assertions in `test/artifacts/e2e.json`, which Git ignores and npm excludes.
|
|
107
108
|
Run `npm run ci:local` before any future push. Publishing and pushing are separate operator actions.
|
|
108
109
|
|
|
@@ -134,3 +135,91 @@ No paid calls, current price checks, media QA, or platform approval occur here.
|
|
|
134
135
|
|
|
135
136
|
Run `node test/mutate-formats.mjs` to repeat the three deliberate faults and restore the files.
|
|
136
137
|
It records failures and the restored pass in `test/artifacts/formats-mutations.json`.
|
|
138
|
+
|
|
139
|
+
### Owner feedback checks
|
|
140
|
+
|
|
141
|
+
`agentic-ads qa-rules` lists R1 through R26. Use `agentic-ads qa-rules show R5`
|
|
142
|
+
to read one rule. MCP provides `list_qa_rules` and `get_qa_rule` with an `id` argument.
|
|
143
|
+
|
|
144
|
+
Each format declares `kinds` and `feedbackRules`. A format plan includes
|
|
145
|
+
`reviewChecklist` entries and the six instructions in `costRatchet`.
|
|
146
|
+
Every entry starts as `NOT CHECKED`. This status blocks shipping.
|
|
147
|
+
A reviewer must record PASS or FAIL with a measured value. Voice and sound fit need the owner's ear.
|
|
148
|
+
|
|
149
|
+
The JavaScript API exports `listRules()`, `getRule(id)`, and `checklistFor(format)`.
|
|
150
|
+
Pass a format descriptor from `getFormat(id)` to `checklistFor`.
|
|
151
|
+
Rules use `blocking` severity. `applies` names format kinds; `all` covers every format.
|
|
152
|
+
Story formats include stickman, POV, before-after, lo-fi, sitcom, and storytime.
|
|
153
|
+
Sitcom has dialogue. Talking-object has visible speech. All current formats have captions.
|
|
154
|
+
|
|
155
|
+
Run `node test/mutate-qa.mjs` to verify three deliberate faults and restore the files.
|
|
156
|
+
Results go to `test/artifacts/qa-mutations.json` and `test/artifacts/e2e.json`.
|
|
157
|
+
|
|
158
|
+
## Hook catalog
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
node bin/agentic-ads hooks
|
|
162
|
+
node bin/agentic-ads hooks show shocked-face
|
|
163
|
+
node bin/agentic-ads hooks pick --format pov --emotion curiosity --housing
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The catalog contains 42 hooks. Each includes format IDs, emotion, kind, risk, a housing flag, and a short rationale.
|
|
167
|
+
The patterns paraphrase the supplied playbook and style catalog. They include the six requested owner feedback openings.
|
|
168
|
+
No research records, personal data, or account IDs are included.
|
|
169
|
+
|
|
170
|
+
MCP exposes `list_hooks({format?, emotion?, housing?})`, `get_hook({id})`, and `pick_hooks({format, emotion?, housing?})`.
|
|
171
|
+
CLI and MCP picks return all matching hooks in catalog order, with cold-start scores.
|
|
172
|
+
The original `list-hooks` command and MCP tool still return the whole catalog.
|
|
173
|
+
Format, emotion, and housing filters combine with AND. Invalid filters fail; no matches return an empty list.
|
|
174
|
+
|
|
175
|
+
The library exports `listHooks`, `getHook`, and `pickHooks(filter, scores = {})`.
|
|
176
|
+
Picks rank matching hooks by descending score. Catalog order resolves ties.
|
|
177
|
+
`selectFor(site, goal, filter = {})` retains its low-risk rule and returns one hook or `null`.
|
|
178
|
+
The original two-argument call still works. Filters apply before score ranking.
|
|
179
|
+
`housing: true` keeps only hooks with `housingSafe: true`. It does not filter by risk level, so medium-risk hooks can still appear.
|
|
180
|
+
|
|
181
|
+
`housingSafe` means the pattern passes housing lint with generic slot values and contains no eligibility claim, invented number, or testimonial.
|
|
182
|
+
It does not approve arbitrary slot values or rendered media. Fill every slot and lint the final copy before use.
|
|
183
|
+
Every result still requires human review. Personal savings, occupancy, interviews, and broken-trust claims remain outside the housing-safe set.
|
|
184
|
+
|
|
185
|
+
Run `node test/mutate-hooks.mjs` to verify three single-line faults and restore the catalog.
|
|
186
|
+
Evidence goes to `test/artifacts/hooks-mutations.json`. E2E results go to `test/artifacts/e2e.json`.
|
|
187
|
+
`test/hook-expectations.json` pins every ID and housing flag; review changes to this file explicitly.
|
|
188
|
+
|
|
189
|
+
## HTTP API for admin sites
|
|
190
|
+
|
|
191
|
+
Start `node bin/agentic-ads serve`. The defaults are `127.0.0.1:8787`.
|
|
192
|
+
Use `--host H` and `--port N` to change them. Port `0` selects an available port.
|
|
193
|
+
The startup JSON reports the bound port. SIGTERM closes the server and its connections.
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
node bin/agentic-ads serve --port 8787
|
|
197
|
+
curl http://127.0.0.1:8787/health
|
|
198
|
+
curl http://127.0.0.1:8787/v1/formats/sitcom
|
|
199
|
+
curl http://127.0.0.1:8787/v1/qa-rules/R5
|
|
200
|
+
curl -H 'Content-Type: application/json' -d '{"copy":"Browse listings","category":"housing"}' http://127.0.0.1:8787/v1/lint
|
|
201
|
+
curl -H 'Content-Type: application/json' -d '{"format":"sitcom","site":"demo"}' http://127.0.0.1:8787/v1/plan
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
GET routes: `/health`, `/v1/formats`, `/v1/formats/:id`, `/v1/hooks`, `/v1/templates`,
|
|
205
|
+
`/v1/qa-rules`, and `/v1/qa-rules/:id`. Health returns `{"status":"ok"}`.
|
|
206
|
+
POST routes: `/v1/lint` and `/v1/plan`. Their results match the CLI and MCP tools.
|
|
207
|
+
Blocked lint findings return HTTP 200 with `status: "blocked"`.
|
|
208
|
+
Plans only produce dry-run results. Any `apply` field is refused, including nested fields and query parameters.
|
|
209
|
+
JSON bodies have a 64 KiB limit. Requests have a ten-second timeout.
|
|
210
|
+
Errors return JSON without stacks: 400 invalid input, 401 unauthorized, 403 forbidden host, 404 missing route or item,
|
|
211
|
+
405 wrong method, 408 timeout, 413 oversized body, and 415 unsupported media type.
|
|
212
|
+
|
|
213
|
+
Set `AGENTIC_ADS_TOKEN` to require `Authorization: Bearer <token>` on every route, including health.
|
|
214
|
+
The token needs 32 or more characters and must not start or end with spaces. Without a token, the server accepts only loopback `Host` values with its own port. This blocks DNS rebinding from a web page.
|
|
215
|
+
A non-loopback host requires this setting. The server never logs bodies or tokens.
|
|
216
|
+
Responses containing the configured token are refused. Use TLS through your admin site's server for remote access.
|
|
217
|
+
|
|
218
|
+
An admin site's backend can call these routes and keep the token in server-side settings.
|
|
219
|
+
Proxy calls through that backend for browser clients. CORS headers are absent by default.
|
|
220
|
+
`AGENTIC_ADS_CORS_ORIGIN=https://admin.example` allows that exact origin in response headers.
|
|
221
|
+
Unauthenticated browser preflight requests still return 401 when a token is set.
|
|
222
|
+
Direct cross-origin browser calls with bearer headers therefore require the backend proxy.
|
|
223
|
+
|
|
224
|
+
Run `node test/mutate-api.mjs` to prove authentication, the Host check, the token length rule, body limits, and apply checks detect deliberate faults.
|
|
225
|
+
The script restores the source and writes `test/artifacts/api-mutations.json`.
|
package/bin/agentic-ads
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
import { execute, tools } from '../src/index.js';
|
|
3
3
|
try {
|
|
4
4
|
let [action, ...argv] = process.argv.slice(2);
|
|
5
|
+
if (action === 'serve') {
|
|
6
|
+
await (await import('../src/http.js')).serve(argv);
|
|
7
|
+
} else {
|
|
5
8
|
const args = {};
|
|
9
|
+
if (action === 'hooks') {
|
|
10
|
+
action = argv[0] === 'show' ? 'get_hook' : argv[0] === 'pick' ? 'pick_hooks' : 'list_hooks';
|
|
11
|
+
if (action === 'get_hook') { argv.shift(); args.id = argv.shift(); }
|
|
12
|
+
if (action === 'pick_hooks' || argv[0] === 'list') argv.shift();
|
|
13
|
+
}
|
|
14
|
+
if (action === 'qa-rules') {
|
|
15
|
+
action = argv[0] === 'show' ? 'get_qa_rule' : 'list_qa_rules';
|
|
16
|
+
if (action === 'get_qa_rule') { argv.shift(); args.id = argv.shift(); }
|
|
17
|
+
}
|
|
6
18
|
if (action === 'formats') {
|
|
7
19
|
action = argv[0] === 'show' ? 'get_format' : 'list_formats';
|
|
8
20
|
if (action === 'get_format') { argv.shift(); args.id = argv.shift(); }
|
|
9
21
|
}
|
|
10
22
|
if (action === 'plan' && argv.some(arg => arg === '--format' || arg.startsWith('--format='))) action = 'plan_format';
|
|
11
23
|
const schema = tools.find(t => t.name === action)?.inputSchema;
|
|
12
|
-
if (!schema) throw new Error('Use formats, list-templates, list-hooks, lint, or
|
|
24
|
+
if (!schema) throw new Error('Use hooks, qa-rules, formats, list-templates, list-hooks, lint, plan, or serve');
|
|
13
25
|
while (argv.length) {
|
|
14
26
|
const option = argv.shift(), separator = option.indexOf('=');
|
|
15
27
|
const flag = separator < 0 ? option : option.slice(0, separator);
|
|
@@ -31,6 +43,7 @@ try {
|
|
|
31
43
|
const result = execute(action, args);
|
|
32
44
|
console.log(JSON.stringify(result));
|
|
33
45
|
if (result.status === 'blocked' || result.policy?.status === 'blocked') process.exitCode = 2;
|
|
46
|
+
}
|
|
34
47
|
} catch (error) {
|
|
35
48
|
console.log(JSON.stringify({ error: error.message })); process.exitCode = 1;
|
|
36
49
|
}
|
package/bin/agentic-ads-mcp
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { execute, tools } from '../src/index.js';
|
|
4
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
3
5
|
// MCP stdio uses one JSON-RPC message per line. stdout is protocol-only.
|
|
4
6
|
const send = message => process.stdout.write(JSON.stringify(message) + '\n');
|
|
5
7
|
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
@@ -17,7 +19,7 @@ function receive(line) {
|
|
|
17
19
|
if (method === 'initialize') {
|
|
18
20
|
if (typeof params.protocolVersion !== 'string') { fail(id, -32602, 'Missing protocol version'); return; }
|
|
19
21
|
initialized = true;
|
|
20
|
-
result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version
|
|
22
|
+
result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version } };
|
|
21
23
|
} else if (method === 'ping') result = {};
|
|
22
24
|
else if (!initialized) { fail(id, -32002, 'Initialize first'); return; }
|
|
23
25
|
else if (method === 'tools/list') result = { tools };
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-ads-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Offline ad templates, hook selection, policy lint, and planning tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": { "node": ">=20" },
|
|
7
7
|
"license": "MIT",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/itsryanarmani/agentic-ads-engine.git" },
|
|
9
|
+
"homepage": "https://github.com/itsryanarmani/agentic-ads-engine#readme",
|
|
10
|
+
"bugs": { "url": "https://github.com/itsryanarmani/agentic-ads-engine/issues" },
|
|
8
11
|
"exports": { ".": "./src/index.js", "./formats": "./src/formats/index.js", "./templates": "./src/templates/index.js", "./hooks": "./src/hooks/index.js", "./policy": "./src/policy/index.js", "./providers": "./src/providers/index.js" },
|
|
9
12
|
"bin": { "agentic-ads": "./bin/agentic-ads", "agentic-ads-mcp": "./bin/agentic-ads-mcp" },
|
|
10
13
|
"files": ["src/", "bin/", "README.md", "LICENSE"],
|
package/src/formats/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checklistFor, costRatchet } from '../qa/index.js';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
const library = JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'));
|
|
3
4
|
const costs = ['free', 'cheap', 'mid', 'expensive'], risks = ['low', 'medium', 'high'];
|
|
@@ -28,7 +29,8 @@ export function planFormat(input = {}) {
|
|
|
28
29
|
const stages = f.stages.map(id => ({ id, provider: f.providers[id], estimatedCostUsd: f.providers[id].estimatedCostUsd }));
|
|
29
30
|
const sum = key => Math.round(stages.reduce((n, s) => n + s.estimatedCostUsd[key], 0) * 1000) / 1000;
|
|
30
31
|
return { format: f.id, site: input.site, goal, dryRun: true, applied: false, spendUsd: 0, stages,
|
|
32
|
+
reviewChecklist: checklistFor(f), costRatchet,
|
|
31
33
|
qaGates: f.qaGates, policyRisk: f.policyRisk, riskNotes: f.riskNotes,
|
|
32
34
|
total: { low: sum('low'), high: sum('high'), note: f.estimatedCostUsd.note },
|
|
33
|
-
warnings: ['Plan only; no providers or QA checks run.', 'Historical source review dates are not current price verification.'] };
|
|
35
|
+
warnings: ['Plan only; no providers or QA checks run.', 'NOT CHECKED blocks shipping. Record PASS or FAIL with a measured value. Voice fit and sound fit need the owner’s ear.', 'Historical source review dates are not current price verification.'] };
|
|
34
36
|
}
|
package/src/formats/library.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "screen-voiceover",
|
|
4
|
+
"kinds": ["captions"],
|
|
5
|
+
"feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
|
|
4
6
|
"name": "Screen recording + voiceover",
|
|
5
7
|
"description": "Record a real or generic checklist UI with timed narration.",
|
|
6
8
|
"aspectRatios": ["9:16"],
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
},
|
|
19
21
|
{
|
|
20
22
|
"id": "text-broll",
|
|
23
|
+
"kinds": ["captions"],
|
|
24
|
+
"feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
|
|
21
25
|
"name": "Text over b-roll",
|
|
22
26
|
"description": "Animate exact caption cards over supplied moving footage.",
|
|
23
27
|
"aspectRatios": ["9:16"],
|
|
@@ -35,6 +39,8 @@
|
|
|
35
39
|
},
|
|
36
40
|
{
|
|
37
41
|
"id": "whiteboard",
|
|
42
|
+
"kinds": ["captions"],
|
|
43
|
+
"feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
|
|
38
44
|
"name": "Whiteboard doodle",
|
|
39
45
|
"description": "Draw SVG ideas to narration word times.",
|
|
40
46
|
"aspectRatios": ["9:16"],
|
|
@@ -52,6 +58,8 @@
|
|
|
52
58
|
},
|
|
53
59
|
{
|
|
54
60
|
"id": "stickman",
|
|
61
|
+
"kinds": ["captions", "story", "character"],
|
|
62
|
+
"feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
55
63
|
"name": "Stickman explainer",
|
|
56
64
|
"description": "Animate a shared SVG character through a problem and answer.",
|
|
57
65
|
"aspectRatios": ["9:16"],
|
|
@@ -69,6 +77,8 @@
|
|
|
69
77
|
},
|
|
70
78
|
{
|
|
71
79
|
"id": "pov",
|
|
80
|
+
"kinds": ["captions", "story", "character"],
|
|
81
|
+
"feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
72
82
|
"name": "POV discovery",
|
|
73
83
|
"description": "Use consistent first-person clips with a title and checklist reveal.",
|
|
74
84
|
"aspectRatios": ["9:16"],
|
|
@@ -86,6 +96,8 @@
|
|
|
86
96
|
},
|
|
87
97
|
{
|
|
88
98
|
"id": "telestrator",
|
|
99
|
+
"kinds": ["captions"],
|
|
100
|
+
"feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
|
|
89
101
|
"name": "Telestrator annotation",
|
|
90
102
|
"description": "Circle relevant UI details with word-timed SVG strokes.",
|
|
91
103
|
"aspectRatios": ["9:16"],
|
|
@@ -103,6 +115,8 @@
|
|
|
103
115
|
},
|
|
104
116
|
{
|
|
105
117
|
"id": "before-after",
|
|
118
|
+
"kinds": ["captions", "story"],
|
|
119
|
+
"feedbackRules": ["R1", "R2", "R5", "R6", "R7", "R9", "R10", "R12", "R14", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
106
120
|
"name": "Before and after",
|
|
107
121
|
"description": "Wipe from confusing paperwork to an actual checklist.",
|
|
108
122
|
"aspectRatios": ["9:16"],
|
|
@@ -120,6 +134,8 @@
|
|
|
120
134
|
},
|
|
121
135
|
{
|
|
122
136
|
"id": "news-parody",
|
|
137
|
+
"kinds": ["captions"],
|
|
138
|
+
"feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
|
|
123
139
|
"name": "News parody",
|
|
124
140
|
"description": "Use branded news cards with sourced facts and narration.",
|
|
125
141
|
"aspectRatios": ["9:16"],
|
|
@@ -137,6 +153,8 @@
|
|
|
137
153
|
},
|
|
138
154
|
{
|
|
139
155
|
"id": "lo-fi",
|
|
156
|
+
"kinds": ["captions", "story", "character", "lo-fi"],
|
|
157
|
+
"feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R16", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
140
158
|
"name": "Handheld lo-fi UGC",
|
|
141
159
|
"description": "Use synthetic handheld scenes and narration with fast reaction cuts.",
|
|
142
160
|
"aspectRatios": ["9:16"],
|
|
@@ -154,6 +172,8 @@
|
|
|
154
172
|
},
|
|
155
173
|
{
|
|
156
174
|
"id": "sitcom",
|
|
175
|
+
"kinds": ["captions", "story", "character", "dialogue"],
|
|
176
|
+
"feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15", "R17", "R18", "R19", "R20", "R21", "R22", "R23", "R26"],
|
|
157
177
|
"name": "Multi-camera sitcom",
|
|
158
178
|
"description": "Cut fictional dialogue between shared sets, angles, and silent reactions.",
|
|
159
179
|
"aspectRatios": ["9:16"],
|
|
@@ -171,6 +191,8 @@
|
|
|
171
191
|
},
|
|
172
192
|
{
|
|
173
193
|
"id": "storytime",
|
|
194
|
+
"kinds": ["captions", "story", "character"],
|
|
195
|
+
"feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
174
196
|
"name": "Storytime twist",
|
|
175
197
|
"description": "A fictional narrator moves from confession to checklist payoff.",
|
|
176
198
|
"aspectRatios": ["9:16"],
|
|
@@ -188,6 +210,8 @@
|
|
|
188
210
|
},
|
|
189
211
|
{
|
|
190
212
|
"id": "talking-object",
|
|
213
|
+
"kinds": ["captions", "character", "speech"],
|
|
214
|
+
"feedbackRules": ["R1", "R3", "R4", "R6", "R7", "R8", "R10", "R11", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
|
|
191
215
|
"name": "Talking checklist",
|
|
192
216
|
"description": "Animate an illustrated object mouth to narration.",
|
|
193
217
|
"aspectRatios": ["9:16"],
|
package/src/hooks/index.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
+
import { getFormat } from '../formats/index.js';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { mkdir, readFile, open, rename, unlink } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
5
6
|
export const hooks = Object.freeze(JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'))
|
|
6
|
-
.map(hook => Object.freeze({ ...hook, tags: Object.freeze(hook.tags) })));
|
|
7
|
+
.map(hook => Object.freeze({ ...hook, tags: Object.freeze(hook.tags), formats: Object.freeze(hook.formats) })));
|
|
8
|
+
export const emotions = Object.freeze(['shock', 'fear', 'curiosity', 'relief', 'humor', 'pride']);
|
|
9
|
+
/** Filters combine with AND; returned descriptors are independent copies. */
|
|
10
|
+
export function listHooks(filter = {}) {
|
|
11
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter) ||
|
|
12
|
+
Object.keys(filter).some(k => !['format', 'emotion', 'housing'].includes(k))) throw new Error('Invalid hook filters');
|
|
13
|
+
if (filter.format !== undefined) getFormat(filter.format);
|
|
14
|
+
if (filter.emotion !== undefined && !emotions.includes(filter.emotion)) throw new Error('Invalid emotion');
|
|
15
|
+
if (filter.housing !== undefined && typeof filter.housing !== 'boolean') throw new Error('Invalid housing filter');
|
|
16
|
+
return structuredClone(hooks.filter(h => (filter.format === undefined || h.formats.includes(filter.format)) &&
|
|
17
|
+
(filter.emotion === undefined || h.emotion === filter.emotion) && (!filter.housing || h.housingSafe)));
|
|
18
|
+
}
|
|
19
|
+
export function getHook(id) {
|
|
20
|
+
const hook = hooks.find(h => h.id === id);
|
|
21
|
+
if (!hook) throw new Error('Unknown hook: ' + String(id));
|
|
22
|
+
return structuredClone(hook);
|
|
23
|
+
}
|
|
24
|
+
/** Rank only matching hooks. Catalog order resolves equal scores. */
|
|
25
|
+
export function pickHooks(filter, scores = {}) {
|
|
26
|
+
if (!filter || filter.format === undefined) throw new Error('Missing required argument: format');
|
|
27
|
+
validate(scores);
|
|
28
|
+
return listHooks(filter).sort((a, b) => (scores[b.id] ?? 0) - (scores[a.id] ?? 0));
|
|
29
|
+
}
|
|
7
30
|
function validate(scores) {
|
|
8
31
|
if (!scores || typeof scores !== 'object' || Array.isArray(scores)) throw new Error('Invalid scores');
|
|
9
32
|
for (const [id, value] of Object.entries(scores)) {
|
|
@@ -48,11 +71,11 @@ export async function writeScores(directory, site, scores) {
|
|
|
48
71
|
* @param {{id:string, scores?:Record<string,number>}} site
|
|
49
72
|
* @param {string} goal
|
|
50
73
|
*/
|
|
51
|
-
export function selectFor(site, goal) {
|
|
74
|
+
export function selectFor(site, goal, filter = {}) {
|
|
52
75
|
if (!site || typeof site !== 'object' || Array.isArray(site)) throw new Error('Invalid site');
|
|
53
76
|
pathFor('.', site.id);
|
|
54
77
|
const scores = validate(site.scores ?? {});
|
|
55
|
-
const eligible =
|
|
78
|
+
const eligible = listHooks(filter).filter(h => h.risk === 'low' && h.tags.includes(goal));
|
|
56
79
|
const ranked = eligible.map((hook, order) => ({ hook, order, score: scores[hook.id] ?? 0 }));
|
|
57
80
|
ranked.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
58
81
|
return ranked.length ? structuredClone(ranked[0].hook) : null;
|
package/src/hooks/library.json
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
[
|
|
2
|
-
{"id":"door","tags":["discovery"],"pattern":"Take a closer look at {{subject}}","risk":"low"},
|
|
3
|
-
{"id":"compare","tags":["comparison","discovery"],"pattern":"Compare {{subject}} with the details in view","risk":"low"},
|
|
4
|
-
{"id":"help","tags":["discovery","help"],"pattern":"Need help exploring {{subject}}? Start here","risk":"low"},
|
|
5
|
-
{"id":"ugc","tags":["experience"],"pattern":"Here is what I observed about {{subject}}","risk":"medium"},
|
|
6
|
-
{"id":"before-after","tags":["comparison"],"pattern":"Before and after: {{subject}}. Virtual staging preview.","risk":"medium"},
|
|
7
|
-
{"id":"update","tags":["news"],"pattern":"Brand update: {{subject}} — {{source}}","risk":"high"}
|
|
2
|
+
{"id":"door","tags":["discovery"],"pattern":"Take a closer look at {{subject}}","risk":"low","kind":"spoken line","formats":["pov","lo-fi"],"emotion":"curiosity","housingSafe":true,"note":"A direct invitation opens a reveal without making a claim."},
|
|
3
|
+
{"id":"compare","tags":["comparison","discovery"],"pattern":"Compare {{subject}} with the details in view","risk":"low","kind":"caption","formats":["text-broll","telestrator"],"emotion":"curiosity","housingSafe":true,"note":"Visible details give a comparison something concrete to show."},
|
|
4
|
+
{"id":"help","tags":["discovery","help"],"pattern":"Need help exploring {{subject}}? Start here","risk":"low","kind":"spoken line","formats":["screen-voiceover","whiteboard"],"emotion":"relief","housingSafe":true,"note":"A clear question leads directly into the useful step."},
|
|
5
|
+
{"id":"ugc","tags":["experience"],"pattern":"Here is what I observed about {{subject}}","risk":"medium","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"pride","housingSafe":false,"note":"First-person delivery adds immediacy; observations need verification."},
|
|
6
|
+
{"id":"before-after","tags":["comparison"],"pattern":"Before and after: {{subject}}. Virtual staging preview.","risk":"medium","kind":"first-frame visual","formats":["before-after"],"emotion":"shock","housingSafe":false,"note":"A split view makes the change legible; staging needs review."},
|
|
7
|
+
{"id":"update","tags":["news"],"pattern":"Brand update: {{subject}} — {{source}}","risk":"high","kind":"caption","formats":["news-parody"],"emotion":"curiosity","housingSafe":false,"note":"An update creates a reason to listen; verify the source."},
|
|
8
|
+
{"id":"shocked-face","tags":["discovery"],"pattern":"Open on an illustrated shocked face; cut next to the cause: {{subject}}.","kind":"first-frame visual","formats":["pov","sitcom","storytime"],"emotion":"shock","risk":"medium","housingSafe":true,"note":"Reaction first creates a question that the next shot answers."},
|
|
9
|
+
{"id":"all-savings","tags":["discovery"],"pattern":"I spent all my savings on this house. {{subject}}","kind":"spoken line","formats":["storytime","lo-fi","sitcom"],"emotion":"fear","risk":"high","housingSafe":false,"note":"Personal stakes start a story quickly; do not present invented experience as testimony."},
|
|
10
|
+
{"id":"slick-agent","tags":["discovery"],"pattern":"The slick agent called {{subject}} risk-free. That broke my trust.","kind":"spoken line","formats":["sitcom","storytime"],"emotion":"shock","risk":"high","housingSafe":false,"note":"Broken trust supplies conflict; the quoted promise still fails policy lint."},
|
|
11
|
+
{"id":"someone-lives-there","tags":["discovery"],"pattern":"Wait, someone lives there? Check the occupancy details for {{subject}}.","kind":"spoken line","formats":["pov","storytime","sitcom"],"emotion":"shock","risk":"medium","housingSafe":false,"note":"An unexpected obstacle turns a reveal into a story; verify occupancy first."},
|
|
12
|
+
{"id":"scare-beat","tags":["discovery"],"pattern":"A brief suspense hit lands as {{subject}} appears; cut straight to the explanation.","kind":"sound","formats":["pov","sitcom","stickman"],"emotion":"fear","risk":"medium","housingSafe":true,"note":"A sound accent marks the obstacle without leaving dead air."},
|
|
13
|
+
{"id":"street-interview","tags":["discovery"],"pattern":"Street interview: What would you check first about {{subject}}?","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"curiosity","risk":"medium","housingSafe":false,"note":"A direct question opens a conversation; synthetic interviews can imply false testimony."},
|
|
14
|
+
{"id":"door-crack","tags":["discovery"],"pattern":"Start with a door already opening onto {{subject}}.","kind":"first-frame visual","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Movement starts the reveal before an introduction slows it down."},
|
|
15
|
+
{"id":"key-turn","tags":["discovery"],"pattern":"A key clicks as the view opens onto {{subject}}.","kind":"sound","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A sound tied to visible action focuses attention on the reveal."},
|
|
16
|
+
{"id":"caption-cut","tags":["discovery"],"pattern":"Pause here. Look closer at {{subject}}.","kind":"caption","formats":["text-broll"],"emotion":"shock","risk":"low","housingSafe":true,"note":"A short text interruption sets up a concrete visual payoff."},
|
|
17
|
+
{"id":"detail-circle","tags":["discovery"],"pattern":"Start with a pen circling a detail in {{subject}}.","kind":"first-frame visual","formats":["telestrator","whiteboard"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"An annotation directs attention to the evidence immediately."},
|
|
18
|
+
{"id":"cursor-open","tags":["discovery"],"pattern":"The cursor opens {{subject}} in the first frame.","kind":"first-frame visual","formats":["screen-voiceover"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"The useful action begins before the viewer has to wait."},
|
|
19
|
+
{"id":"marker-question","tags":["discovery"],"pattern":"Draw a question mark beside {{subject}} as the narration begins.","kind":"first-frame visual","formats":["whiteboard"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Drawing and speech share a beat so the opening stays active."},
|
|
20
|
+
{"id":"paper-chaos","tags":["discovery"],"pattern":"Scattered notes slide into a checklist for {{subject}}.","kind":"first-frame visual","formats":["before-after","stickman"],"emotion":"relief","risk":"low","housingSafe":true,"note":"Visible organization supplies a payoff without promising a financial outcome."},
|
|
21
|
+
{"id":"tab-overload","tags":["discovery"],"pattern":"More tabs. Still questions about {{subject}}.","kind":"caption","formats":["text-broll","screen-voiceover"],"emotion":"humor","risk":"low","housingSafe":true,"note":"Dry humor makes a familiar research problem easy to recognize."},
|
|
22
|
+
{"id":"checklist-speaks","tags":["discovery"],"pattern":"I am the checklist. Ask me about {{subject}}.","kind":"spoken line","formats":["talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A talking prop makes the useful tool part of the scene."},
|
|
23
|
+
{"id":"paper-eyebrow","tags":["discovery"],"pattern":"An illustrated checklist raises an eyebrow beside {{subject}}.","kind":"first-frame visual","formats":["talking-object","stickman"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A small character reaction delivers a visual joke immediately."},
|
|
24
|
+
{"id":"mid-question","tags":["discovery"],"pattern":"And the next question is about {{subject}}.","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Starting inside a thought avoids a slow introduction."},
|
|
25
|
+
{"id":"look-again","tags":["discovery"],"pattern":"Look again at {{subject}}. What detail stands out?","kind":"spoken line","formats":["pov","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A focused question creates a reason to inspect the next shot."},
|
|
26
|
+
{"id":"prop-entry","tags":["discovery"],"pattern":"Hands bring a checklist for {{subject}} into the opening frame.","kind":"first-frame visual","formats":["lo-fi","pov"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A useful prop creates movement and gives the narration a subject."},
|
|
27
|
+
{"id":"deadpan-paper","tags":["discovery"],"pattern":"{{subject}} brought paperwork to the party.","kind":"spoken line","formats":["sitcom","talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A dry joke adds character without mocking people or communities."},
|
|
28
|
+
{"id":"question-stack","tags":["discovery"],"pattern":"{{subject}}: what needs a closer look?","kind":"caption","formats":["text-broll","pov"],"emotion":"fear","risk":"low","housingSafe":true,"note":"A question introduces uncertainty without inventing a threat."},
|
|
29
|
+
{"id":"slow-down","tags":["discovery"],"pattern":"Start with the details of {{subject}}.","kind":"spoken line","formats":["whiteboard","screen-voiceover"],"emotion":"relief","risk":"low","housingSafe":true,"note":"A simple next step turns uncertainty into a useful demonstration."},
|
|
30
|
+
{"id":"tick-reveal","tags":["discovery"],"pattern":"A soft tick lands as the checklist for {{subject}} opens.","kind":"sound","formats":["screen-voiceover","whiteboard"],"emotion":"relief","risk":"low","housingSafe":true,"note":"An action sound makes the tool reveal clear."},
|
|
31
|
+
{"id":"wipe-to-guide","tags":["discovery"],"pattern":"Wipe from loose notes to a guide for {{subject}}.","kind":"first-frame visual","formats":["before-after","text-broll"],"emotion":"relief","risk":"low","housingSafe":true,"note":"The contrast shows organization rather than a promised result."},
|
|
32
|
+
{"id":"side-by-side","tags":["discovery"],"pattern":"{{subject}}: put the details side by side.","kind":"caption","formats":["text-broll","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A comparison creates a clear visual task."},
|
|
33
|
+
{"id":"quiet-reveal","tags":["discovery"],"pattern":"A soft door creak introduces {{subject}}.","kind":"sound","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A small physical sound supports the reveal without competing with speech."},
|
|
34
|
+
{"id":"record-scratch","tags":["discovery"],"pattern":"A short record scratch interrupts the gesture toward {{subject}}.","kind":"sound","formats":["sitcom","talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A timed interruption gives the visual joke a clear beat."},
|
|
35
|
+
{"id":"heartbeat-question","tags":["discovery"],"pattern":"A quiet heartbeat underscores the question about {{subject}}; resolve it with the guide.","kind":"sound","formats":["pov","storytime"],"emotion":"fear","risk":"medium","housingSafe":true,"note":"Suspense leads into an answer rather than unsupported danger."},
|
|
36
|
+
{"id":"ready-notes","tags":["discovery"],"pattern":"Notes ready. Time to explore {{subject}}.","kind":"caption","formats":["text-broll","screen-voiceover"],"emotion":"pride","risk":"low","housingSafe":true,"note":"Preparation offers an achievable action rather than an outcome claim."},
|
|
37
|
+
{"id":"draw-the-route","tags":["discovery"],"pattern":"A drawn path connects the question about {{subject}} to a checklist.","kind":"first-frame visual","formats":["whiteboard","stickman"],"emotion":"relief","risk":"low","housingSafe":true,"note":"The visual explanation moves from problem to next step."},
|
|
38
|
+
{"id":"fictional-aside","tags":["discovery"],"pattern":"Fictional scene: the paperwork for {{subject}} gets its own chair.","kind":"spoken line","formats":["sitcom","storytime"],"emotion":"humor","risk":"medium","housingSafe":true,"note":"An obvious fictional joke gives the scene personality without false testimony."},
|
|
39
|
+
{"id":"envelope-open","tags":["discovery"],"pattern":"Open a generic envelope to reveal a guide to {{subject}}.","kind":"first-frame visual","formats":["pov","text-broll"],"emotion":"curiosity","risk":"medium","housingSafe":true,"note":"The physical reveal adds texture without making an offer claim."},
|
|
40
|
+
{"id":"empty-box","tags":["discovery"],"pattern":"{{subject}}: which detail fills this box?","kind":"caption","formats":["whiteboard","screen-voiceover"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"An unfinished visual invites attention to the explanation."},
|
|
41
|
+
{"id":"magnifier","tags":["discovery"],"pattern":"A drawn magnifier moves across the details of {{subject}}.","kind":"first-frame visual","formats":["stickman","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A moving focus point makes the inspection visible."},
|
|
42
|
+
{"id":"question-to-action","tags":["discovery"],"pattern":"Questions about {{subject}}? Open the guide alongside the details.","kind":"spoken line","formats":["screen-voiceover","whiteboard"],"emotion":"relief","risk":"low","housingSafe":true,"note":"A direct bridge connects the opening question to a useful action."},
|
|
43
|
+
{"id":"guide-in-hand","tags":["discovery"],"pattern":"A hand sets a guide beside the notes on {{subject}}.","kind":"first-frame visual","formats":["pov","lo-fi"],"emotion":"pride","risk":"low","housingSafe":true,"note":"An ordinary action shows preparation without claiming a purchase or result."}
|
|
8
44
|
]
|
package/src/http.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { execute } from './index.js';
|
|
4
|
+
const digest = value => createHash('sha256').update(value).digest();
|
|
5
|
+
const hasApply = value => value && typeof value === 'object' && Object.entries(value).some(([key, item]) => key === 'apply' || hasApply(item));
|
|
6
|
+
export async function serve(argv) {
|
|
7
|
+
const options = { host: '127.0.0.1', port: 8787 }, seen = new Set();
|
|
8
|
+
while (argv.length) {
|
|
9
|
+
const option = argv.shift(), split = option.indexOf('=');
|
|
10
|
+
const key = (split < 0 ? option : option.slice(0, split)).slice(2);
|
|
11
|
+
const value = split < 0 ? argv.shift() : option.slice(split + 1);
|
|
12
|
+
if (!option.startsWith('--') || !['host', 'port'].includes(key) || seen.has(key) || !value) throw new Error('Invalid serve option');
|
|
13
|
+
seen.add(key); options[key] = value;
|
|
14
|
+
}
|
|
15
|
+
if (!/^\d+$/.test(String(options.port)) || Number(options.port) > 65535) throw new Error('Invalid port');
|
|
16
|
+
options.port = Number(options.port);
|
|
17
|
+
const token = process.env.AGENTIC_ADS_TOKEN || '';
|
|
18
|
+
const loopback = ['localhost', '::1', '127.0.0.1'].includes(options.host);
|
|
19
|
+
if (!loopback && !token) throw new Error('Non-loopback host requires AGENTIC_ADS_TOKEN');
|
|
20
|
+
if (token && token !== token.trim()) throw new Error('AGENTIC_ADS_TOKEN must not start or end with spaces');
|
|
21
|
+
if (token && token.length < 32) throw new Error('AGENTIC_ADS_TOKEN needs 32 or more characters');
|
|
22
|
+
let boundPort = 0;
|
|
23
|
+
// Pin localhost to loopback rather than trusting hostname resolution.
|
|
24
|
+
if (options.host === 'localhost') options.host = '127.0.0.1';
|
|
25
|
+
const expected = digest('Bearer ' + token);
|
|
26
|
+
const cors = process.env.AGENTIC_ADS_CORS_ORIGIN;
|
|
27
|
+
if (cors) {
|
|
28
|
+
try { if (!/^https?:$/.test(new URL(cors).protocol) || new URL(cors).origin !== cors) throw new Error(); }
|
|
29
|
+
catch { throw new Error('Invalid CORS origin'); }
|
|
30
|
+
}
|
|
31
|
+
const server = createServer({ requestTimeout: 10000, headersTimeout: 10000, connectionsCheckingInterval: 1000 }, (req, res) => {
|
|
32
|
+
// Without a token, accept only loopback Host values. This blocks DNS rebinding.
|
|
33
|
+
const hostOk = token || [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`].includes(String(req.headers.host).toLowerCase());
|
|
34
|
+
const timer = setTimeout(() => { send(408, { error: 'Request timeout' }); req.resume(); }, 10000);
|
|
35
|
+
res.on('close', () => clearTimeout(timer));
|
|
36
|
+
function send(status, value, headers = {}) {
|
|
37
|
+
if (res.writableEnded || res.destroyed) return;
|
|
38
|
+
let body = JSON.stringify(value);
|
|
39
|
+
if (token && (body.includes(token) || body.includes(JSON.stringify(token).slice(1, -1)))) {
|
|
40
|
+
status = 400; body = JSON.stringify({ error: 'Invalid request' });
|
|
41
|
+
}
|
|
42
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(status >= 400 && status !== 413 ? { connection: 'close' } : {}), ...headers });
|
|
43
|
+
res.end(body);
|
|
44
|
+
}
|
|
45
|
+
if (cors && req.headers.origin === cors) {
|
|
46
|
+
res.setHeader('access-control-allow-origin', cors); res.setHeader('vary', 'Origin');
|
|
47
|
+
}
|
|
48
|
+
if (token && !timingSafeEqual(digest(req.headers.authorization ?? ''), expected)) { send(401, { error: 'Unauthorized' }); req.resume(); return; }
|
|
49
|
+
let url;
|
|
50
|
+
if (!hostOk) { send(403, { error: 'Forbidden host' }); req.resume(); return; }
|
|
51
|
+
try { url = new URL(req.url, 'http://localhost'); } catch { send(400, { error: 'Invalid URL' }); return; }
|
|
52
|
+
const routes = { '/health': null, '/v1/formats': 'list_formats', '/v1/hooks': 'list-hooks',
|
|
53
|
+
'/v1/templates': 'list-templates', '/v1/qa-rules': 'list_qa_rules', '/v1/lint': 'lint', '/v1/plan': 'plan_format' };
|
|
54
|
+
const detail = url.pathname.match(/^\/v1\/(formats|qa-rules)\/([^/]+)$/);
|
|
55
|
+
const action = detail ? (detail[1] === 'formats' ? 'get_format' : 'get_qa_rule') : routes[url.pathname];
|
|
56
|
+
if (!detail && !Object.hasOwn(routes, url.pathname)) { send(404, { error: 'Not found' }); req.resume(); return; }
|
|
57
|
+
const method = ['lint', 'plan_format'].includes(action) ? 'POST' : 'GET';
|
|
58
|
+
if (req.method !== method) { send(405, { error: 'Method not allowed' }, { allow: method }); req.resume(); return; }
|
|
59
|
+
if (url.searchParams.has('apply')) { send(400, { error: 'Apply is unavailable' }); req.resume(); return; }
|
|
60
|
+
function run(args) {
|
|
61
|
+
try { send(200, action === null ? { status: 'ok' } : execute(action, args)); }
|
|
62
|
+
catch { send(detail ? 404 : 400, { error: detail ? 'Not found' : 'Invalid arguments' }); }
|
|
63
|
+
}
|
|
64
|
+
if (method === 'GET') {
|
|
65
|
+
try { run(detail ? { id: decodeURIComponent(detail[2]) } : {}); }
|
|
66
|
+
catch { send(400, { error: 'Invalid URL' }); }
|
|
67
|
+
req.resume(); return;
|
|
68
|
+
}
|
|
69
|
+
if (req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
70
|
+
send(415, { error: 'Use application/json' }); req.resume(); return;
|
|
71
|
+
}
|
|
72
|
+
let size = 0, chunks = [], rejected = false;
|
|
73
|
+
req.on('error', () => { rejected = true; chunks = []; });
|
|
74
|
+
req.on('data', chunk => {
|
|
75
|
+
if (rejected) return;
|
|
76
|
+
size += chunk.length;
|
|
77
|
+
if (size > 65536) { rejected = true; chunks = []; send(413, { error: 'Body too large' }); return; }
|
|
78
|
+
chunks.push(chunk);
|
|
79
|
+
});
|
|
80
|
+
req.on('end', () => {
|
|
81
|
+
if (rejected || res.writableEnded) return;
|
|
82
|
+
let args;
|
|
83
|
+
try {
|
|
84
|
+
args = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
85
|
+
if (hasApply(args)) { send(400, { error: 'Apply is unavailable' }); return; }
|
|
86
|
+
} catch { send(400, { error: 'Invalid JSON' }); return; }
|
|
87
|
+
run(args);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
server.on('clientError', (error, socket) => {
|
|
91
|
+
if (!socket.writable) return;
|
|
92
|
+
const status = error.code === 'ERR_HTTP_REQUEST_TIMEOUT' ? '408 Request Timeout' : '400 Bad Request';
|
|
93
|
+
const body = JSON.stringify({ error: 'Invalid or incomplete request' });
|
|
94
|
+
socket.end(`HTTP/1.1 ${status}\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
95
|
+
});
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
server.once('error', () => reject(new Error('Unable to start HTTP server')));
|
|
98
|
+
server.listen(options, resolve);
|
|
99
|
+
});
|
|
100
|
+
boundPort = server.address().port;
|
|
101
|
+
console.log(JSON.stringify({ status: 'listening', port: server.address().port }));
|
|
102
|
+
const stop = () => { server.close(); server.closeAllConnections(); };
|
|
103
|
+
process.once('SIGTERM', stop); process.once('SIGINT', stop);
|
|
104
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import { listRules, getRule } from './qa/index.js';
|
|
2
|
+
export { listRules, getRule, checklistFor, costRatchet } from './qa/index.js';
|
|
1
3
|
import { listFormats, getFormat, planFormat } from './formats/index.js';
|
|
2
4
|
export { listFormats, getFormat, planFormat };
|
|
3
5
|
import { templates } from './templates/index.js';
|
|
4
|
-
import { hooks, selectFor } from './hooks/index.js';
|
|
6
|
+
import { hooks, selectFor, listHooks, getHook, pickHooks, emotions } from './hooks/index.js';
|
|
5
7
|
import { lint } from './policy/index.js';
|
|
6
8
|
import { templateProvider, guardCost } from './providers/index.js';
|
|
7
|
-
export { templates, hooks, selectFor, lint, templateProvider, guardCost };
|
|
9
|
+
export { listHooks, getHook, pickHooks, templates, hooks, selectFor, lint, templateProvider, guardCost };
|
|
8
10
|
export { readScores, writeScores } from './hooks/index.js';
|
|
9
11
|
const string = { type: 'string' };
|
|
12
|
+
const hookFilters = { format: string, emotion: { type: 'string', enum: emotions }, housing: { type: 'boolean' } };
|
|
10
13
|
const fields = {
|
|
14
|
+
list_hooks: hookFilters, pick_hooks: hookFilters, get_hook: { id: string },
|
|
15
|
+
list_qa_rules: {}, get_qa_rule: { id: string },
|
|
11
16
|
list_formats: { costClass: { type: 'string', enum: ['free', 'cheap', 'mid', 'expensive'] }, risk: { type: 'string', enum: ['low', 'medium', 'high'] } },
|
|
12
17
|
get_format: { id: string },
|
|
13
18
|
plan_format: { format: string, site: string, goal: string, dryRun: { type: 'boolean' } },
|
|
@@ -16,8 +21,8 @@ const fields = {
|
|
|
16
21
|
plan: { site: string, goal: string, template: string, ratio: { enum: ['4:5', '9:16'], type: 'string' }, copy: string, category: { enum: ['housing', 'none'], type: 'string' }, apply: { type: 'boolean' }, dryRun: { type: 'boolean' } },
|
|
17
22
|
};
|
|
18
23
|
export const tools = Object.entries(fields).map(([name, properties]) => ({
|
|
19
|
-
name, description: ({ list_formats: 'List video formats with optional cost and risk filters.', get_format: 'Get a video format descriptor.', plan_format: 'Plan video stages offline. Apply is unavailable.', 'list-templates': 'List starter template descriptors.', 'list-hooks': 'List hook patterns and risks.', lint: 'Check ad copy; findings always require review.', plan: 'Preview a zero-cost plan. Apply is unavailable.' })[name],
|
|
20
|
-
inputSchema: { type: 'object', properties, additionalProperties: false, ...({ lint: ['copy'], get_format: ['id'], plan_format: ['format', 'site'] }[name] ? { required: { lint: ['copy'], get_format: ['id'], plan_format: ['format', 'site'] }[name] } : {}) },
|
|
24
|
+
name, description: ({ list_hooks: 'List hooks with optional filters.', pick_hooks: 'Pick matching hooks in stable score order.', get_hook: 'Get one hook.', list_qa_rules: 'List owner feedback QA rules.', get_qa_rule: 'Get an owner feedback QA rule.', list_formats: 'List video formats with optional cost and risk filters.', get_format: 'Get a video format descriptor.', plan_format: 'Plan video stages offline. Apply is unavailable.', 'list-templates': 'List starter template descriptors.', 'list-hooks': 'List hook patterns and risks.', lint: 'Check ad copy; findings always require review.', plan: 'Preview a zero-cost plan. Apply is unavailable.' })[name],
|
|
25
|
+
inputSchema: { type: 'object', properties, additionalProperties: false, ...({ pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] ? { required: { pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] } : {}) },
|
|
21
26
|
}));
|
|
22
27
|
/** Shared CLI/MCP dispatch. Unknown inputs fail before any action. */
|
|
23
28
|
export function execute(action, args = {}) {
|
|
@@ -30,6 +35,11 @@ export function execute(action, args = {}) {
|
|
|
30
35
|
if (!Object.hasOwn(fields[action], key) || typeof value !== field.type || (field.enum && !field.enum.includes(value))) throw new Error('Invalid argument');
|
|
31
36
|
if (typeof value === 'string' && value.length > 20000) throw new Error('Argument too long');
|
|
32
37
|
}
|
|
38
|
+
if (action === 'list_hooks') return listHooks(args);
|
|
39
|
+
if (action === 'pick_hooks') return pickHooks(args);
|
|
40
|
+
if (action === 'get_hook') return getHook(args.id);
|
|
41
|
+
if (action === 'list_qa_rules') return listRules();
|
|
42
|
+
if (action === 'get_qa_rule') return getRule(args.id);
|
|
33
43
|
if (action === 'list_formats') return listFormats(args);
|
|
34
44
|
if (action === 'get_format') return getFormat(args.id);
|
|
35
45
|
if (action === 'plan_format') return planFormat(args);
|
package/src/qa/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const rules = JSON.parse(readFileSync(new URL('./rules.json', import.meta.url), 'utf8'));
|
|
3
|
+
export function listRules() { return structuredClone(rules); }
|
|
4
|
+
export function getRule(id) {
|
|
5
|
+
const rule = rules.find(r => r.id === id);
|
|
6
|
+
if (!rule) throw new Error('Unknown QA rule: ' + String(id) + '. Use qa-rules to list available IDs.');
|
|
7
|
+
return structuredClone(rule);
|
|
8
|
+
}
|
|
9
|
+
/** Accept a format descriptor. Review status never implies that checks ran. */
|
|
10
|
+
export function checklistFor(format) {
|
|
11
|
+
return format.feedbackRules.map(id => {
|
|
12
|
+
const { title, check } = getRule(id);
|
|
13
|
+
return { id, title, check, status: 'NOT CHECKED' };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export const costRatchet = "Set a hard cap before you spend. One writer per spend log.\nReuse faces, sets, stills, clips and voice ids from earlier videos first.\nPick one lip-sync model for a format and keep it. Do not repeat a bake-off.\nDo not make the same still twice. Do not pay for an unused line.\nWrite `COST_NOTES.md`. Show cost per stage and against the last video of the format.\nA video that costs more than the last one of its format needs a one-line reason.";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"id": "R1", "title": "Wild emotional hook in the first second.", "check": "Watch 0 to 1 s with sound off. Record what frame 0.0 s shows. It must be a strong emotion or an odd event. In a format with a person, R20 applies.", "origin": "Round 3 hook was \"not intriguing\".", "severity": "blocking", "applies": ["all"]},
|
|
3
|
+
{"id": "R2", "title": "Cause comes before effect on screen. Show the action, then the result, then who reacts. In a format with a person, the reaction face of R20 comes first. Then the order is action, result, reaction, all by 3 s.", "check": "Step through 0 to 3 s. No character appears without a reason. No object is already broken.", "origin": "Sitcom: door fell, people appeared at 0:02.", "severity": "blocking", "applies": ["story"]},
|
|
4
|
+
{"id": "R3", "title": "Change the camera angle, not only the zoom.", "check": "Look at the frames: the scene-cut filter cannot see a zoom. About one new angle or pose every 2.5 s. Cuts differ in height, pose or distance. A side change stays on one side of the 180-degree line (R21).", "origin": "\"Different left and right views, not slow zooms.\"", "severity": "blocking", "applies": ["character"]},
|
|
5
|
+
{"id": "R4", "title": "The same person must show different poses and angles.", "check": "Same face, outfit and hair across all shots. At least 3 poses.", "origin": "Marble lady needed jump cuts.", "severity": "blocking", "applies": ["character"]},
|
|
6
|
+
{"id": "R5", "title": "Show problem, agitation, then solution. Make the agitation hurt and be funny.", "check": "Mark the three beats on the timeline. Agitation has at least 3 quick cuts, one prop each.", "origin": "\"Highlight problem and solution agitation.\"", "severity": "blocking", "applies": ["story"]},
|
|
7
|
+
{"id": "R6", "title": "No dead gaps. No silent still over 0.3 s. Tail after the last sound under 0.3 s.", "check": "`freezedetect=n=-60dB:d=0.3` crossed with `silencedetect=n=-35dB:d=0.25`. A range in both is a dead gap.", "origin": "Round 4 had dead gaps.", "severity": "blocking", "applies": ["all"]},
|
|
8
|
+
{"id": "R7", "title": "Speed up the picture, not the voice. Normal speech pace.", "check": "Words per second from whisper: 2 to 3. Average shot length: 1.5 to 2.2 s for a lo-fi story (the gate in `qa.md`), 1.2 to 1.5 s only for a fast punch edit. No shot over 2.5 s.", "origin": "\"Sound speed too much, pace better.\"", "severity": "blocking", "applies": ["all"]},
|
|
9
|
+
{"id": "R8", "title": "Voice, face and ethnicity must match.", "check": "A person listens to each voice with the face. Log the voice id per character.", "origin": "\"Ethnicity of source does not match.\"", "severity": "blocking", "applies": ["character"]},
|
|
10
|
+
{"id": "R9", "title": "A story the viewer can follow. It is personal and coherent, in the first person, as a dramatization. It makes no factual claim.", "check": "Read the captions alone. They tell one story in the first person.", "origin": "Meltdown: \"random words\".", "severity": "blocking", "applies": ["story"]},
|
|
11
|
+
{"id": "R10", "title": "Captions have spaces. At most 3 words. Time them to the audio.", "check": "Measure the pixel gap: at least 14 px of clear gap between words at 1080 px. Compare caption start with the whisper word start (under 0.15 s).", "origin": "Caption spacing and sync.", "severity": "blocking", "applies": ["captions"]},
|
|
12
|
+
{"id": "R11", "title": "Mouth movement matches speech. Mouths close in laugh breaks.", "check": "Manual spot check until measured: mouth movement starts within 100 to 200 ms of voice onset (see `ad-video-editing` qa.md section 6). Where a lip-landmark tool ran, give its correlation (0.4 or more) and the tool name. List each failing line.", "origin": "Sitcom v2 and v3.", "severity": "blocking", "applies": ["dialogue", "speech"]},
|
|
13
|
+
{"id": "R12", "title": "Never cut to a static image that makes no sense.", "check": "Check each cut. Every shot has motion or a reason to hold.", "origin": "Sitcom v2 feedback.", "severity": "blocking", "applies": ["all"]},
|
|
14
|
+
{"id": "R13", "title": "Back-and-forth dialogue is wanted. Keep it and add more.", "check": "Count speaker changes. At least 4 in a sitcom.", "origin": "\"Love the dialogue.\"", "severity": "blocking", "applies": ["dialogue"]},
|
|
15
|
+
{"id": "R14", "title": "Sound is required. The ad also works muted.", "check": "Play with sound off. The story and the offer still read. Ship both files.", "origin": "Round 3 had no sound.", "severity": "blocking", "applies": ["all"]},
|
|
16
|
+
{"id": "R15", "title": "Hold the object identity. A phone stays a phone.", "check": "Compare held objects in frames across the clip.", "origin": "Phone turned into a notepad.", "severity": "blocking", "applies": ["character"]},
|
|
17
|
+
{"id": "R16", "title": "Lo-fi organic look. No logo. No text except captions, the dramatization tag and one end card with the offer.", "check": "Look at the frames. No logo, no studio polish.", "origin": "Polished images were rejected.", "severity": "blocking", "applies": ["lo-fi"]},
|
|
18
|
+
{"id": "R17", "title": "Housing safety. \"dramatization\" label. No invented numbers. No real people. No children.", "check": "Run the policy lint on all text and captions.", "origin": "Housing special ad category.", "severity": "blocking", "applies": ["all"]},
|
|
19
|
+
{"id": "R18", "title": "Cost goes down each time. Report cost per video by stage.", "check": "Compare with the last video of the same format. Explain any rise.", "origin": "Goal: cut cost 50 to 75 percent.", "severity": "blocking", "applies": ["all"]},
|
|
20
|
+
{"id": "R19", "title": "Do not show an event the video model cannot render. Show damage another way.", "check": "Render one clip first. Look at 3 frames of the event. No duplicate objects (two doors), no melting. If it fails, change the beat (cracking wall, falling plaster).", "origin": "\"No crash, two doors, AI artifact.\"", "severity": "blocking", "applies": ["all"]},
|
|
21
|
+
{"id": "R20", "title": "In a format with a person, the first frame is that person's reaction face. The cause follows in the next shot or two (see R2). This replaces the 0.0 s check in R1 for those formats.", "check": "Frame 0.0 s: a face, eyes and mouth readable, emotion matches the hook. The cause is on screen by 3 s.", "origin": "\"Shocked face in the first frame.\"", "severity": "blocking", "applies": ["character"]},
|
|
22
|
+
{"id": "R21", "title": "Keep screen direction. Each person stays on the same side of the frame in every shot. Change the angle on one side of the 180-degree line. Do not cross it.", "check": "List left/right per shot for each person. No swap, including cutaways.", "origin": "Street interview: host moved from right to left.", "severity": "blocking", "applies": ["dialogue"]},
|
|
23
|
+
{"id": "R22", "title": "A conversation looks like one: eyelines, head turns, small hand gestures, a slow push-in on the punchline. Do the push-in in the edit as a crop. Ask the model for small gestures only.", "check": "Per talking shot: where does the person look? Count eyeline-matched reverse shots (at least 1). Note the crop push-in. OWNER CHECK for how natural it looks.", "origin": "\"Talk as if to someone, more hand movement, zoom in on the guy.\"", "severity": "blocking", "applies": ["dialogue"]},
|
|
24
|
+
{"id": "R23", "title": "Captions never look jumbled.", "check": "Compile `swiftc -O ocr.swift` (macOS Vision, `VNRecognizeTextRequest`, accurate, language correction off). Run it on one frame per caption from the final render. Retry once on an `e5rt` crash. Character error rate (edit distance divided by expected length, upper case, spaces collapsed) 0.10 or less. Do not run `swift ocr.swift` (it hangs).", "origin": "\"Some letters get jumbled.\"", "severity": "blocking", "applies": ["captions"]},
|
|
25
|
+
{"id": "R24", "title": "Show the tool of the format. A street interview shows the microphone in the host's hand. Name the mic in the motion prompt and include it in the R15 frame comparison.", "check": "Frame check: mic visible in every host shot, same shape in each.", "origin": "Interview had no visible mic.", "severity": "blocking", "applies": ["interview"]},
|
|
26
|
+
{"id": "R25", "title": "A horror-comic variant needs a scare beat: something moves behind a door, a sting, a reaction. Then it resolves as a joke.", "check": "Mark the beat on the timeline. A sting starts within 0.2 s of the beat, then a reaction shot. OWNER CHECK for how scary it feels.", "origin": "\"Feel a little bit scared.\"", "severity": "blocking", "applies": ["horror"]},
|
|
27
|
+
{"id": "R26", "title": "A personal story has a clear hook: the loss, the place, the trust that broke, the surprise. The offer is on the end card. The narrator never says the product worked. See R9 and R17.", "check": "Caption read: one sentence per beat, in that order. No line claims a result.", "origin": "\"I spent all my savings on this house ... a slick agent gave me a lien ... someone lives there.\"", "severity": "blocking", "applies": ["story"]}
|
|
28
|
+
]
|