openbriefing 0.4.0 → 0.5.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 CHANGED
@@ -62,6 +62,18 @@ npx openbriefing ghost push posts/ # write the approved ones
62
62
 
63
63
  `pull` never changes Ghost and never overwrites a file. `push` writes only approved files, only between `<!-- obs:start -->` and `<!-- obs:end -->` in the post's Code injection header, and skips a post whose tags have not changed. The folder holds your posts: keep it out of public repositories.
64
64
 
65
+ To have the tags written when a post is published, run the hook where Ghost can reach it:
66
+
67
+ ```bash
68
+ export GHOST_WEBHOOK_SECRET=… # any long random text
69
+ export ANTHROPIC_API_KEY=…
70
+ export OBS_HOOK_URL=https://your-hook.example # registers the two webhooks on start
71
+ npx openbriefing ghost hook # POST /ghost, GET /healthz, on PORT or 8080
72
+ npx openbriefing ghost draft --dry-run # the same for posts already published
73
+ ```
74
+
75
+ The hook drafts with Claude (`OBS_MODEL` picks the model), keeps only highlights it finds in the post, and stamps its block `<!-- obs:auto … -->`. Edit the tags in the post's Code injection and they are yours: the hook never writes to that post again. It leaves members-only posts and posts under 200 words alone. These tags are published before you read them; use `pull` and `push` if you would rather approve each one.
76
+
65
77
  ## Use the parser
66
78
 
67
79
  ```ts
package/dist/cli.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * openbriefing tags <file.yaml | post.md | -> [--format html | feed | json] [--marked] [--allow-draft]
4
4
  * openbriefing ghost pull <folder> [--slug <slug>]
5
5
  * openbriefing ghost push <folder> [--slug <slug>] [--dry-run]
6
+ * openbriefing ghost draft [--slug <slug>] [--limit <n>] [--dry-run]
7
+ * openbriefing ghost hook [--port <n>]
6
8
  * openbriefing check <url | file | -> [--url <page url>] [--json]
7
9
  * openbriefing check <feed url | file> --feed [--pages]
8
10
  * openbriefing check <sitemap url | file> --sitemap [--limit <n>]
@@ -30,19 +32,26 @@ const USAGE = `Usage:
30
32
  openbriefing tags <file.yaml | post.md | -> [--format html | feed | json] [--marked] [--allow-draft]
31
33
  openbriefing ghost pull <folder> [--slug <slug>]
32
34
  openbriefing ghost push <folder> [--slug <slug>] [--dry-run]
35
+ openbriefing ghost draft [--slug <slug>] [--limit <n>] [--dry-run]
36
+ openbriefing ghost hook [--port <n>]
33
37
  openbriefing check <url | file | -> [--url <page url>] [--json]
34
38
  openbriefing check <feed url | file> --feed [--pages]
35
39
  openbriefing check <sitemap url | file> --sitemap [--limit <n>]
36
40
 
37
41
  tags turns a briefing file into escaped tags. ghost pull writes one file per
38
42
  published post; ghost push writes each approved file's tags into its post
39
- (GHOST_URL and GHOST_ADMIN_API_KEY in the environment). check reads a page,
40
- a feed or a sitemap and prints what a reader sees.
43
+ (GHOST_URL and GHOST_ADMIN_API_KEY in the environment). ghost draft writes
44
+ tags, drafted by Claude (ANTHROPIC_API_KEY), for each published post that has
45
+ none. ghost hook does the same when a post is published: it serves Ghost's
46
+ webhooks (GHOST_WEBHOOK_SECRET; OBS_HOOK_URL registers them). The author edits
47
+ the tags in the post's Code injection, and an edited post is left alone.
48
+ check reads a page, a feed or a sitemap and prints what a reader sees.
41
49
  --format html (meta tags, the default), feed (RSS and Atom elements) or json
42
50
  --marked wrap the tags in the obs:start and obs:end comments
43
51
  --allow-draft write tags for a file whose status is not approved
44
52
  --slug with ghost: one post only
45
- --dry-run with ghost push: say what would be written, and write nothing
53
+ --dry-run with ghost push or draft: say what would be written, and write nothing
54
+ --port with ghost hook: the port to listen on (PORT, or 8080)
46
55
  --url the page's address, when checking a file with no canonical link
47
56
  --feed treat the input as an RSS or Atom feed (detected when omitted)
48
57
  --pages with a feed: also fetch each item's page and say which one stands
@@ -84,6 +93,13 @@ function parseArgs(argv) {
84
93
  else
85
94
  args.url = v;
86
95
  }
96
+ else if (a === "--port") {
97
+ const n = Number.parseInt(argv[++i] ?? "", 10);
98
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
99
+ args.bad = "--port needs a port number";
100
+ else
101
+ args.port = n;
102
+ }
87
103
  else if (a === "--limit") {
88
104
  const n = Number.parseInt(argv[++i] ?? "", 10);
89
105
  if (!Number.isInteger(n) || n < 1)
@@ -379,10 +395,18 @@ function tags(args) {
379
395
  console.log(draft ? `<!-- obs: a draft, not yet approved by the author -->\n${out}` : out);
380
396
  return 0;
381
397
  }
398
+ const printLine = (l) => console.log(`${l.state.padEnd(12)} ${safe(l.slug)}${l.note ? ` (${safe(l.note)})` : ""}`);
399
+ function tally(lines) {
400
+ const count = (s) => lines.filter((l) => l.state === s).length;
401
+ const states = [...new Set(lines.map((l) => l.state))];
402
+ console.log(lines.length === 0 ? "Nothing to do." : `\n${states.map((s) => `${count(s)} ${s}`).join(", ")}`);
403
+ return count("error") > 0 ? 1 : 0;
404
+ }
382
405
  async function ghost(args) {
383
406
  const action = args.target;
384
407
  const folder = args.rest[0];
385
- if (args.bad || (action !== "pull" && action !== "push") || !folder)
408
+ const filed = action === "pull" || action === "push";
409
+ if (args.bad || !(filed || action === "draft" || action === "hook") || (filed && !folder))
386
410
  return usage(args);
387
411
  const url = process.env.GHOST_URL;
388
412
  const key = process.env.GHOST_ADMIN_API_KEY;
@@ -392,16 +416,39 @@ async function ghost(args) {
392
416
  }
393
417
  const { Ghost, pull, push } = await import("./ghost.js");
394
418
  const client = new Ghost({ url, key });
419
+ if (action === "draft" || action === "hook") {
420
+ const { claudeDrafter, draftAll, hookServer, registerWebhooks } = await import("./hook.js");
421
+ const model = process.env.OBS_MODEL;
422
+ const drafter = claudeDrafter(model ? { model } : {});
423
+ if (action === "draft") {
424
+ const opts = { dryRun: args.dryRun, onLine: printLine };
425
+ if (args.slug !== undefined)
426
+ opts.slug = args.slug;
427
+ if (args.limit !== undefined)
428
+ opts.limit = args.limit;
429
+ return tally(await draftAll(client, drafter, opts));
430
+ }
431
+ const secret = process.env.GHOST_WEBHOOK_SECRET;
432
+ if (!secret) {
433
+ console.error("Set GHOST_WEBHOOK_SECRET: any long random text. Ghost signs each webhook with it.");
434
+ return 2;
435
+ }
436
+ const target = process.env.OBS_HOOK_URL;
437
+ if (target)
438
+ for (const line of await registerWebhooks(client, `${target.replace(/\/+$/u, "")}/ghost`, secret))
439
+ console.log(line);
440
+ const port = args.port ?? (Number(process.env.PORT) || 8080);
441
+ await new Promise((done) => hookServer({ ghost: client, secret, drafter }).listen(port, done));
442
+ console.log(`openbriefing ghost hook is listening on ${port}`);
443
+ return new Promise(() => { });
444
+ }
395
445
  const opts = { dryRun: args.dryRun };
396
446
  if (args.slug !== undefined)
397
447
  opts.slug = args.slug;
398
448
  const lines = action === "pull" ? await pull(client, folder, args.slug) : await push(client, folder, opts);
399
449
  for (const l of lines)
400
- console.log(`${l.state.padEnd(12)} ${safe(l.slug)}${l.note ? ` (${safe(l.note)})` : ""}`);
401
- const count = (s) => lines.filter((l) => l.state === s).length;
402
- const states = [...new Set(lines.map((l) => l.state))];
403
- console.log(lines.length === 0 ? "Nothing to do." : `\n${states.map((s) => `${count(s)} ${s}`).join(", ")}`);
404
- return count("error") > 0 ? 1 : 0;
450
+ printLine(l);
451
+ return tally(lines);
405
452
  }
406
453
  async function main(argv) {
407
454
  const args = parseArgs(argv);
package/dist/ghost.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface GhostPost {
7
7
  updated_at: string;
8
8
  codeinjection_head: string | null;
9
9
  plaintext?: string | null;
10
+ /** "public", or who may read it. */
11
+ visibility?: string;
10
12
  }
11
13
  export type FetchLike = (url: string, init: {
12
14
  method: string;
@@ -36,6 +38,8 @@ export declare class Ghost {
36
38
  private call;
37
39
  /** Every published post, with its plain text. */
38
40
  posts(slug?: string): Promise<GhostPost[]>;
41
+ /** Adds a webhook to the integration this key belongs to. */
42
+ addWebhook(event: string, target: string, secret: string): Promise<void>;
39
43
  /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
40
44
  setHead(post: GhostPost, head: string): Promise<void>;
41
45
  }
@@ -47,6 +51,8 @@ export declare function briefInHead(head: string, url: string): ObsBrief | undef
47
51
  * undefined when hand-written tags remain that this cannot safely move.
48
52
  */
49
53
  export declare function headWith(head: string, block: string): string | undefined;
54
+ /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
55
+ export declare const SLUG: RegExp;
50
56
  export interface Line {
51
57
  slug: string;
52
58
  state: "written" | "would write" | "unchanged" | "draft" | "skipped" | "pulled" | "kept" | "error";
package/dist/ghost.js CHANGED
@@ -69,6 +69,10 @@ export class Ghost {
69
69
  }
70
70
  return out;
71
71
  }
72
+ /** Adds a webhook to the integration this key belongs to. */
73
+ async addWebhook(event, target, secret) {
74
+ await this.call("POST", "/webhooks/", { webhooks: [{ event, target_url: target, secret, name: `Open Briefing: ${event}` }] });
75
+ }
72
76
  /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
73
77
  async setHead(post, head) {
74
78
  await this.call("PUT", `/posts/${post.id}/`, { posts: [{ codeinjection_head: head, updated_at: post.updated_at }] });
@@ -100,7 +104,7 @@ export function headWith(head, block) {
100
104
  return replaceMarked(cleaned, block);
101
105
  }
102
106
  /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
103
- const SLUG = /^[a-z0-9][a-z0-9_-]*$/iu;
107
+ export const SLUG = /^[a-z0-9][a-z0-9_-]*$/iu;
104
108
  const briefingFiles = (folder) => readdirSync(folder)
105
109
  .filter((f) => /\.(md|markdown|ya?ml)$/iu.test(f))
106
110
  .sort();
package/dist/hook.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { type Server } from "node:http";
2
+ import { type Ghost, type GhostPost, type Line } from "./ghost.js";
3
+ import { type ObsBrief } from "./obs.js";
4
+ /** Writes a briefing file (YAML) for a post. */
5
+ export type Drafter = (post: {
6
+ title: string;
7
+ url: string;
8
+ text: string;
9
+ }) => Promise<string>;
10
+ /** A post shorter than this has nothing for a 200-word summary to shorten. */
11
+ export declare const MIN_WORDS = 200;
12
+ /** The drafting prompt, the kit's `prompt.md`. */
13
+ export declare function draftingPrompt(): string;
14
+ /** A drafter that asks Claude. The key comes from ANTHROPIC_API_KEY. */
15
+ export declare function claudeDrafter(options?: {
16
+ model?: string;
17
+ }): Drafter;
18
+ /** The marked block for a brief the hook wrote, with its stamp. */
19
+ export declare function autoBlock(brief: ObsBrief, url: string): string;
20
+ export type Decision = {
21
+ act: "draft" | "redraft";
22
+ } | {
23
+ act: "leave";
24
+ why: string;
25
+ };
26
+ /** What the hook does for a post, from the post alone. */
27
+ export declare function decide(post: GhostPost): Decision;
28
+ /** A drafted brief for a post, with any highlight the post does not contain taken out. */
29
+ export declare function draftBrief(post: GhostPost, drafter: Drafter): Promise<ObsBrief>;
30
+ /** Drafts and writes one post's tags, when the post is the hook's to write. */
31
+ export declare function autoTag(ghost: Ghost, slug: string, drafter: Drafter, options?: {
32
+ dryRun?: boolean;
33
+ }): Promise<Line>;
34
+ /** Drafts tags for every published post that has none. One post at a time. */
35
+ export declare function draftAll(ghost: Ghost, drafter: Drafter, options?: {
36
+ slug?: string;
37
+ limit?: number;
38
+ dryRun?: boolean;
39
+ onLine?: (line: Line) => void;
40
+ }): Promise<Line[]>;
41
+ /** Ghost signs a webhook as `sha256=<hex HMAC of body + t>, t=<ms>`. */
42
+ export declare function verifySignature(body: string, header: string | undefined, secret: string, now?: number): boolean;
43
+ /** The slug a webhook is about. The payload is untrusted; the post itself is read from Ghost. */
44
+ export declare function slugOf(body: string): string | undefined;
45
+ export declare const HOOK_EVENTS: readonly ["post.published", "post.published.edited"];
46
+ /** Registers the hook's webhooks on the integration the Admin API key belongs to. Safe to repeat. */
47
+ export declare function registerWebhooks(ghost: Ghost, target: string, secret: string): Promise<string[]>;
48
+ export interface HookOptions {
49
+ ghost: Ghost;
50
+ secret: string;
51
+ drafter: Drafter;
52
+ log?: (message: string) => void;
53
+ now?: () => number;
54
+ }
55
+ /**
56
+ * An HTTP server for Ghost's webhooks: POST /ghost and GET /healthz. Ghost
57
+ * gives up after two seconds, so the hook answers 202 and then does the work.
58
+ */
59
+ export declare function hookServer(options: HookOptions): Server & {
60
+ idle(): Promise<void>;
61
+ };
package/dist/hook.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `openbriefing ghost hook` and `ghost draft`: tags written for a post when it
3
+ * is published, with no step for the author.
4
+ *
5
+ * The hook drafts the summary with a model, checks every highlight against the
6
+ * post's text, and writes the marked block into the post's Code injection
7
+ * header. The block carries a stamp, `<!-- obs:auto <fingerprint> -->`. While
8
+ * the tags still match the stamp the block is the hook's; once the author
9
+ * edits them it is theirs, and the hook never writes to that post again.
10
+ */
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { createServer } from "node:http";
14
+ import { checkHighlights, MARK_END, MARK_START, parseBriefing, readMarked, renderTags } from "./briefing.js";
15
+ import { briefInHead, headWith, SLUG } from "./ghost.js";
16
+ import { countWords, fingerprint } from "./obs.js";
17
+ /** A post shorter than this has nothing for a 200-word summary to shorten. */
18
+ export const MIN_WORDS = 200;
19
+ const STAMP = /<!--\s*obs:auto\s+([0-9a-f]{16})\s*-->/u;
20
+ const MAX_AGE_MS = 5 * 60 * 1000;
21
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
22
+ /** The drafting prompt, the kit's `prompt.md`. */
23
+ export function draftingPrompt() {
24
+ // Packed beside dist/ when published; at the repository's root when run from source.
25
+ for (const rel of ["../kit/prompt.md", "../../../kit/prompt.md"]) {
26
+ const path = new URL(rel, import.meta.url);
27
+ if (existsSync(path))
28
+ return readFileSync(path, "utf8");
29
+ }
30
+ throw new Error("The drafting prompt (kit/prompt.md) is missing from this install");
31
+ }
32
+ /** A drafter that asks Claude. The key comes from ANTHROPIC_API_KEY. */
33
+ export function claudeDrafter(options = {}) {
34
+ const system = draftingPrompt();
35
+ return async (post) => {
36
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
37
+ // A key that is not tied to one workspace needs the workspace named.
38
+ const workspace = process.env.ANTHROPIC_WORKSPACE_ID;
39
+ const client = new Anthropic(workspace ? { defaultHeaders: { "anthropic-workspace-id": workspace } } : {});
40
+ const response = await client.beta.messages.create({
41
+ model: options.model ?? "claude-opus-5-5",
42
+ max_tokens: 16000,
43
+ thinking: { type: "adaptive" },
44
+ betas: ["server-side-fallback-2026-07-01"],
45
+ fallbacks: "default",
46
+ system,
47
+ messages: [{ role: "user", content: `Address: ${post.url}\nTitle: ${post.title}\n\n${post.text}` }],
48
+ });
49
+ if (response.stop_reason === "refusal")
50
+ throw new Error("the model declined to draft this post");
51
+ if (response.stop_reason === "max_tokens")
52
+ throw new Error("the draft was cut off");
53
+ return response.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
54
+ };
55
+ }
56
+ /** The marked block for a brief the hook wrote, with its stamp. */
57
+ export function autoBlock(brief, url) {
58
+ const tags = renderTags(brief);
59
+ // The stamp is taken from the tags as a reader parses them, so it holds for exactly what is on the page.
60
+ const read = briefInHead(tags, url);
61
+ return [MARK_START, `<!-- obs:auto ${fingerprint(read ?? brief)} -->`, tags, MARK_END].join("\n");
62
+ }
63
+ /** What the hook does for a post, from the post alone. */
64
+ export function decide(post) {
65
+ if (post.visibility !== undefined && post.visibility !== "public")
66
+ return { act: "leave", why: "the post is not public" };
67
+ const text = post.plaintext ?? "";
68
+ if (countWords(text) < MIN_WORDS)
69
+ return { act: "leave", why: `the post is under ${MIN_WORDS} words` };
70
+ const head = post.codeinjection_head ?? "";
71
+ if (!/obs:/u.test(head))
72
+ return { act: "draft" };
73
+ const marked = readMarked(head);
74
+ const stamp = marked === undefined ? undefined : STAMP.exec(marked)?.[1];
75
+ const live = marked === undefined ? undefined : briefInHead(marked, post.url);
76
+ if (stamp === undefined || !live || fingerprint(live) !== stamp || head.replace(marked, "").includes("obs:"))
77
+ return { act: "leave", why: "the author's own tags" };
78
+ // Still the hook's block. It is redone only when the post changed under a quote.
79
+ checkHighlights(live, text, []);
80
+ if (live.highlights.some((h) => h.inContent === false))
81
+ return { act: "redraft" };
82
+ return { act: "leave", why: "the tags are current" };
83
+ }
84
+ const unfence = (text) => text
85
+ .trim()
86
+ .replace(/^```[a-z]*\r?\n/iu, "")
87
+ .replace(/\r?\n```$/u, "");
88
+ /** A drafted brief for a post, with any highlight the post does not contain taken out. */
89
+ export async function draftBrief(post, drafter) {
90
+ const text = post.plaintext ?? "";
91
+ let problem = "";
92
+ for (let attempt = 0; attempt < 2; attempt++) {
93
+ const briefing = parseBriefing(unfence(await drafter({ title: post.title, url: post.url, text })));
94
+ if (!briefing.brief) {
95
+ problem = briefing.errors.join("; ") || "no summary";
96
+ continue;
97
+ }
98
+ const brief = briefing.brief;
99
+ checkHighlights(brief, text, []);
100
+ brief.highlights = brief.highlights.filter((h) => h.inContent !== false);
101
+ // The address is the post's, whatever the draft said.
102
+ const withUrl = parseBriefing(JSON.stringify({ ...draftFields(brief), url: post.url })).brief;
103
+ return withUrl ?? brief;
104
+ }
105
+ throw new Error(`the draft was not usable: ${problem}`);
106
+ }
107
+ function draftFields(brief) {
108
+ const out = {};
109
+ if (brief.title)
110
+ out.title = brief.title;
111
+ if (brief.headline)
112
+ out.headline = brief.headline;
113
+ for (const s of brief.summaries)
114
+ out[s.from === "inferred" ? "summary" : `summary_${s.tier}`] = s.text;
115
+ if (brief.keep.length > 0)
116
+ out.keep = brief.keep;
117
+ if (brief.highlights.length > 0)
118
+ out.highlights = brief.highlights.map((h) => (h.label ? { text: h.text, label: h.label } : { text: h.text }));
119
+ return out;
120
+ }
121
+ /** Drafts and writes one post's tags, when the post is the hook's to write. */
122
+ export async function autoTag(ghost, slug, drafter, options = {}) {
123
+ if (!SLUG.test(slug))
124
+ return { slug, state: "error", note: "not a post slug" };
125
+ try {
126
+ let post = (await ghost.posts(slug))[0];
127
+ if (!post)
128
+ return { slug, state: "skipped", note: "no published post has this slug" };
129
+ const decision = decide(post);
130
+ if (decision.act === "leave")
131
+ return { slug, state: "kept", note: decision.why };
132
+ if (options.dryRun)
133
+ return { slug, state: "would write" };
134
+ const brief = await draftBrief(post, drafter);
135
+ for (let attempt = 0;; attempt++) {
136
+ const next = headWith(post.codeinjection_head ?? "", autoBlock(brief, post.url));
137
+ if (next === undefined)
138
+ return { slug, state: "kept", note: "the author's own tags" };
139
+ try {
140
+ await ghost.setHead(post, next);
141
+ return { slug, state: "written", note: decision.act === "redraft" ? "redone, the post changed under a quote" : "drafted" };
142
+ }
143
+ catch (err) {
144
+ // The post was saved while the draft was being written: read it again, once.
145
+ if (attempt > 0 || !(err instanceof Error) || !err.message.includes("409"))
146
+ throw err;
147
+ const fresh = (await ghost.posts(slug))[0];
148
+ if (!fresh || decide(fresh).act === "leave")
149
+ return { slug, state: "kept", note: "the post changed while the draft was written" };
150
+ post = fresh;
151
+ }
152
+ }
153
+ }
154
+ catch (err) {
155
+ return { slug, state: "error", note: err instanceof Error ? err.message : String(err) };
156
+ }
157
+ }
158
+ /** Drafts tags for every published post that has none. One post at a time. */
159
+ export async function draftAll(ghost, drafter, options = {}) {
160
+ const lines = [];
161
+ let written = 0;
162
+ for (const post of await ghost.posts(options.slug)) {
163
+ if (options.limit !== undefined && written >= options.limit)
164
+ break;
165
+ const opts = options.dryRun === undefined ? {} : { dryRun: options.dryRun };
166
+ const line = await autoTag(ghost, post.slug, drafter, opts);
167
+ if (line.state === "written" || line.state === "would write")
168
+ written++;
169
+ lines.push(line);
170
+ options.onLine?.(line);
171
+ }
172
+ return lines;
173
+ }
174
+ /** Ghost signs a webhook as `sha256=<hex HMAC of body + t>, t=<ms>`. */
175
+ export function verifySignature(body, header, secret, now = Date.now()) {
176
+ const match = /^sha256=([0-9a-f]{64}), t=(\d+)$/u.exec(header ?? "");
177
+ if (!match || secret === "")
178
+ return false;
179
+ const [, sig, ts] = match;
180
+ if (Math.abs(now - Number(ts)) > MAX_AGE_MS)
181
+ return false;
182
+ const expected = createHmac("sha256", secret).update(`${body}${ts}`).digest();
183
+ return timingSafeEqual(Buffer.from(sig, "hex"), expected);
184
+ }
185
+ /** The slug a webhook is about. The payload is untrusted; the post itself is read from Ghost. */
186
+ export function slugOf(body) {
187
+ try {
188
+ const slug = JSON.parse(body).post?.current?.slug;
189
+ return typeof slug === "string" && SLUG.test(slug) ? slug : undefined;
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ export const HOOK_EVENTS = ["post.published", "post.published.edited"];
196
+ /** Registers the hook's webhooks on the integration the Admin API key belongs to. Safe to repeat. */
197
+ export async function registerWebhooks(ghost, target, secret) {
198
+ const out = [];
199
+ for (const event of HOOK_EVENTS) {
200
+ try {
201
+ await ghost.addWebhook(event, target, secret);
202
+ out.push(`${event}: registered`);
203
+ }
204
+ catch (err) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ // Ghost answers 422 when this address already has the event.
207
+ out.push(message.includes("422") ? `${event}: already registered` : `${event}: ${message}`);
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * An HTTP server for Ghost's webhooks: POST /ghost and GET /healthz. Ghost
214
+ * gives up after two seconds, so the hook answers 202 and then does the work.
215
+ */
216
+ export function hookServer(options) {
217
+ const log = options.log ?? ((m) => console.log(m));
218
+ // One post at a time per slug: a publish and the edit that follows it never race.
219
+ const busy = new Map();
220
+ const run = (slug) => {
221
+ const next = (busy.get(slug) ?? Promise.resolve())
222
+ .then(() => autoTag(options.ghost, slug, options.drafter))
223
+ .then((line) => log(`${line.state} ${slug}${line.note ? ` (${line.note})` : ""}`))
224
+ .catch((err) => log(`error ${slug} (${err instanceof Error ? err.message : String(err)})`))
225
+ .finally(() => {
226
+ if (busy.get(slug) === next)
227
+ busy.delete(slug);
228
+ });
229
+ busy.set(slug, next);
230
+ };
231
+ const server = createServer((req, res) => {
232
+ if (req.method === "GET" && req.url === "/healthz") {
233
+ res.writeHead(200, { "content-type": "text/plain" }).end("ok");
234
+ return;
235
+ }
236
+ if (req.method !== "POST" || req.url !== "/ghost") {
237
+ res.writeHead(404).end();
238
+ return;
239
+ }
240
+ const chunks = [];
241
+ let size = 0;
242
+ req.on("data", (c) => {
243
+ size += c.length;
244
+ if (size > MAX_BODY_BYTES) {
245
+ res.writeHead(413).end();
246
+ req.destroy();
247
+ return;
248
+ }
249
+ chunks.push(c);
250
+ });
251
+ req.on("end", () => {
252
+ if (size > MAX_BODY_BYTES)
253
+ return;
254
+ const body = Buffer.concat(chunks).toString("utf8");
255
+ const header = req.headers["x-ghost-signature"];
256
+ if (!verifySignature(body, typeof header === "string" ? header : undefined, options.secret, options.now?.() ?? Date.now())) {
257
+ res.writeHead(401).end();
258
+ return;
259
+ }
260
+ const slug = slugOf(body);
261
+ if (slug === undefined) {
262
+ res.writeHead(400).end();
263
+ return;
264
+ }
265
+ res.writeHead(202).end();
266
+ run(slug);
267
+ });
268
+ });
269
+ server.headersTimeout = 10_000;
270
+ server.requestTimeout = 15_000;
271
+ return Object.assign(server, { idle: async () => void (await Promise.all([...busy.values()])) });
272
+ }
package/kit/prompt.md ADDED
@@ -0,0 +1,40 @@
1
+ You are drafting an author's own summary of their post, in the Open Briefing Standard's briefing-file format. Tools that brief or read posts aloud will use these words in place of a summary they would otherwise write themselves, and they will credit the author. So the draft must sound like the author and say only what the post says.
2
+
3
+ Return one YAML briefing file and nothing else: no preface, no code fence, no notes after it.
4
+
5
+ ```yaml
6
+ url: <the post's address, if you were given it; leave the key out if not>
7
+ headline: <one or two sentences, 40 words or fewer>
8
+ summary: |
9
+ <200 to 350 words, in two to four paragraphs, with a blank line between paragraphs>
10
+ keep:
11
+ - <the one sentence every shorter version must get across>
12
+ highlights:
13
+ - text: <a passage copied from the post, word for word>
14
+ label: <what it is about, 6 words or fewer>
15
+ status: draft
16
+ ```
17
+
18
+ How to write each part:
19
+
20
+ **summary.** 200 to 350 words. Use the author's own sentences where they serve, and their voice everywhere: first person if the post is first person, their vocabulary, their level of formality. Say what the post argues or reports and the reasons or evidence it gives, in the order a listener needs. The first two sentences must stand on their own, because a tool that needs something shorter will often use just those. The summary must make sense to someone who has not seen the title.
21
+
22
+ **headline.** A line someone might hear before deciding whether to listen further. It carries the post's main point, not a teaser for it.
23
+
24
+ **keep.** Up to three lines, most important first. The first is the post's core message in one sentence. Add a second or third only for something a shorter version must not lose, such as a number, a caveat or a name. Each line should also appear in the summary, in the same words.
25
+
26
+ **highlights.** Up to four passages the author might be glad to see quoted on their own. Copy each one from the post exactly, word for word and with its punctuation, 60 words or fewer. Do not join passages from different places, trim words from the middle, or fix the author's wording. If a passage ends in the post with a comma before a closing quotation mark, end your copy before the comma. If nothing in the post reads well alone, leave `highlights` out.
27
+
28
+ **title.** Leave it out, unless the post's own title is longer than 12 words. Then add `title:` with a shorter one.
29
+
30
+ Rules for every line:
31
+
32
+ - Plain text that works both read and heard. No Markdown, no HTML, no emoji, no bullet lists inside a value.
33
+ - No web addresses in any line you write (the `url:` key is the one exception), and no "click here", "see below", "in this post" or "pictured above". No reference to images, tables or layout.
34
+ - Spell out an abbreviation the first time, unless it is normally said as a word.
35
+ - In the lines you write yourself, prefer a single quotation mark to a double one wherever either reads as well. The words end up inside an HTML attribute, where a double quotation mark has to be escaped. A highlight keeps the post's own marks.
36
+ - Say nothing the post does not support. Do not add background, opinion, praise or a conclusion the author did not draw. If the post is unclear on a point, leave the point out.
37
+ - Write in the post's language.
38
+ - Always end the file with `status: draft`. Only the author approves it.
39
+
40
+ The post follows.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbriefing",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Open Briefing Standard (OBS): the reference parser, the checker and the publisher's tag tools for publishers' own summaries.",
5
5
  "keywords": [
6
6
  "open-briefing-standard",
@@ -42,6 +42,7 @@
42
42
  "files": [
43
43
  "dist",
44
44
  "fixtures",
45
+ "kit",
45
46
  "README.md"
46
47
  ],
47
48
  "engines": {
@@ -53,9 +54,10 @@
53
54
  "test": "vitest run",
54
55
  "fixtures": "node ../../scripts/make-fixtures.mjs",
55
56
  "expected": "tsx scripts/write-expected.ts",
56
- "prepack": "rm -rf fixtures && cp -R ../../spec/fixtures fixtures && npm run build"
57
+ "prepack": "rm -rf fixtures kit && cp -R ../../spec/fixtures fixtures && mkdir kit && cp ../../kit/prompt.md kit/prompt.md && npm run build"
57
58
  },
58
59
  "dependencies": {
60
+ "@anthropic-ai/sdk": "^1.8.0",
59
61
  "jsdom": "^26.1.0",
60
62
  "yaml": "^2.9.1"
61
63
  },