@kensio/skills 1.13.1

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.
@@ -0,0 +1,807 @@
1
+ #!/usr/bin/env node
2
+ // Sends the prose of one document to Pangram, a commercial AI-text detector, and
3
+ // reports which passages read as machine-drafted.
4
+ //
5
+ // node pangram-check.mjs post.md
6
+ // node pangram-check.mjs post.md --dry-run guards and cost, send nothing
7
+ // node pangram-check.mjs post.md --print-prose exactly what would be sent
8
+ // node pangram-check.mjs post.md --format markdown
9
+ // node pangram-check.mjs --check-key free, spends no detection call
10
+ //
11
+ // Only the prose goes out. Frontmatter, code, HTML, shortcodes, tables, headings
12
+ // and URLs are removed first, and every reported window carries the source line
13
+ // its passage starts on. Results are cached by content hash, so re-running on an
14
+ // unchanged document costs nothing.
15
+ //
16
+ // The API key is read from the environment or a dotenv file, is never printed,
17
+ // and is redacted from any error text.
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, resolve } from "node:path";
23
+
24
+ const HELP = `usage: pangram-check.mjs [options] <file>
25
+
26
+ --dry-run run the guards, print the cost estimate, send nothing
27
+ --print-prose print the extracted prose and exit
28
+ --format <f> text, markdown or json (default: text)
29
+ --windows <n> how many windows to detail, or "all" (default: 5)
30
+ --min-words <n> refuse below this many words of prose (default: 300)
31
+ --max-units <n> refuse when the estimate exceeds n billable units
32
+ --reject-todo refuse while the file holds TODO markers
33
+ --reject <regex> refuse when this pattern matches the file (repeatable)
34
+ --skip-quotes leave blockquoted material out of the prose
35
+ --plain treat the file as plain text, not markdown
36
+ --model <name> Pangram model selector
37
+ --list-models list the model selectors the key allows, then exit
38
+ --check-key check the key is accepted, then exit
39
+ --refresh ignore any cached result for this text
40
+ --no-cache neither read nor write the cache
41
+ --config <path> use this config file
42
+ --no-config ignore any .pangram-check.json
43
+ --no-color plain output on a terminal
44
+
45
+ Config: .pangram-check.json, found by walking up from the file. Keys are
46
+ minWords, maxUnits, rejectTodo, rejectPatterns, skipQuotes, format, windows,
47
+ model and cache. Flags win over the file.`;
48
+
49
+ const CAUTION = [
50
+ "A high score marks a passage worth rereading. It is not a number to drive down.",
51
+ "Editing to move a detector score is a different activity from writing in your own voice.",
52
+ "Pangram reports whether text reads as machine-generated. It has no opinion on whether the writing is any good.",
53
+ ];
54
+
55
+ // ---- arguments --------------------------------------------------------------
56
+
57
+ const argv = process.argv.slice(2);
58
+ const VALUED = new Set([
59
+ "--format",
60
+ "--windows",
61
+ "--min-words",
62
+ "--max-units",
63
+ "--reject",
64
+ "--model",
65
+ "--config",
66
+ ]);
67
+
68
+ const flags = { reject: [] };
69
+ const positional = [];
70
+ const camel = (name) => name.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
71
+
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i];
74
+ if (!arg.startsWith("--")) {
75
+ positional.push(arg);
76
+ continue;
77
+ }
78
+ const [name, inline] = arg.includes("=")
79
+ ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)]
80
+ : [arg, null];
81
+ if (VALUED.has(name)) {
82
+ const value = inline ?? argv[++i];
83
+ if (value === undefined) fail(2, `${name} needs a value`);
84
+ if (name === "--reject") flags.reject.push(value);
85
+ else flags[camel(name)] = value;
86
+ continue;
87
+ }
88
+ if (inline !== null) fail(2, `${name} takes no value`);
89
+ flags[camel(name)] = true;
90
+ }
91
+
92
+ function fail(code, message) {
93
+ console.error(`✖ ${message}`);
94
+ process.exit(code);
95
+ }
96
+
97
+ if (flags.help || argv.includes("-h")) {
98
+ console.log(HELP);
99
+ process.exit(0);
100
+ }
101
+
102
+ const BASE = process.env.PANGRAM_API_BASE?.trim() || "https://text.external-api.pangram.com";
103
+
104
+ // ---- key -------------------------------------------------------------------
105
+
106
+ const KEY_SOURCES = [
107
+ ["$PANGRAM_API_KEY", () => process.env.PANGRAM_API_KEY?.trim() || null],
108
+ ["$PANGRAM_ENV_FILE", () => fromDotenv(process.env.PANGRAM_ENV_FILE)],
109
+ ["~/.config/pangram/.env", () => fromDotenv(configHome())],
110
+ ["./.env", () => fromDotenv(resolve(process.cwd(), ".env"))],
111
+ ];
112
+
113
+ function configHome() {
114
+ const base = process.env.XDG_CONFIG_HOME?.trim() || resolve(homedir(), ".config");
115
+ return resolve(base, "pangram/.env");
116
+ }
117
+
118
+ function fromDotenv(path) {
119
+ if (!path || !existsSync(path)) return null;
120
+ const line = readFileSync(path, "utf8")
121
+ .split("\n")
122
+ .find((l) =>
123
+ l
124
+ .trim()
125
+ .replace(/^export\s+/, "")
126
+ .startsWith("PANGRAM_API_KEY="),
127
+ );
128
+ if (!line) return null;
129
+ const value = line
130
+ .split("=")
131
+ .slice(1)
132
+ .join("=")
133
+ .trim()
134
+ .replace(/^["']|["']$/g, "");
135
+ return value && !/^(replace|your|<)/i.test(value) ? value : null;
136
+ }
137
+
138
+ function readKey() {
139
+ for (const [, read] of KEY_SOURCES) {
140
+ const value = read();
141
+ if (value) return value;
142
+ }
143
+ console.error("✖ No Pangram API key found. Looked in, in order:\n");
144
+ for (const [label] of KEY_SOURCES) console.error(` ${label}`);
145
+ console.error(`
146
+ Pangram is a paid commercial service. Get a key from https://www.pangram.com
147
+ (dashboard, then API keys), then store it where every repository can reach it:
148
+
149
+ mkdir -p ~/.config/pangram
150
+ printf 'PANGRAM_API_KEY=%s\\n' 'the-key' > ~/.config/pangram/.env
151
+ chmod 600 ~/.config/pangram/.env
152
+
153
+ Or export PANGRAM_API_KEY in the shell, or point $PANGRAM_ENV_FILE at a
154
+ dotenv file that holds it. Check it with:
155
+
156
+ node pangram-check.mjs --check-key
157
+ `);
158
+ process.exit(2);
159
+ }
160
+
161
+ // Resolved on the first call and never before it, so --dry-run, --print-prose
162
+ // and every guard work on a machine that has no key at all.
163
+ let KEY = null;
164
+ const key = () => (KEY ??= readKey());
165
+ const redact = (text) => (KEY ? String(text).split(KEY).join("[REDACTED]") : String(text));
166
+
167
+ // ---- http ------------------------------------------------------------------
168
+
169
+ const STATUS_HELP = {
170
+ 400: "Pangram rejected the request as malformed.",
171
+ 401: "Pangram rejected the API key. Check it with --check-key.",
172
+ 403: "Pangram rejected the API key. Check it with --check-key.",
173
+ 402: "The Pangram account is out of credits. Top it up at https://www.pangram.com",
174
+ 413: "The text is too large for one request. Split the document.",
175
+ 429: "Rate limited by Pangram. Wait and run it again.",
176
+ };
177
+
178
+ async function call(path, init) {
179
+ let response;
180
+ try {
181
+ response = await fetch(`${BASE}${path}`, {
182
+ ...init,
183
+ headers: { "Content-Type": "application/json", "x-api-key": key(), ...(init?.headers ?? {}) },
184
+ });
185
+ } catch (error) {
186
+ throw new Error(`Could not reach ${BASE}: ${redact(error.message)}`);
187
+ }
188
+ const body = await response.text();
189
+ if (!response.ok) {
190
+ const help = STATUS_HELP[response.status] ?? `Pangram returned ${response.status}.`;
191
+ throw new Error(`${help}\n ${redact(body.slice(0, 300))}`);
192
+ }
193
+ try {
194
+ return JSON.parse(body);
195
+ } catch {
196
+ throw new Error(`Pangram returned a body that is not JSON: ${redact(body.slice(0, 200))}`);
197
+ }
198
+ }
199
+
200
+ // ---- key check and model list ----------------------------------------------
201
+
202
+ if (flags.checkKey || flags.listModels) {
203
+ try {
204
+ const models = await call("/models", { method: "GET" });
205
+ const names = Array.isArray(models) ? models : (models.models ?? models.data ?? []);
206
+ console.log("✔ Pangram accepted the API key. No detection call was made.");
207
+ const labels = names.map((m) =>
208
+ typeof m === "string" ? m : (m.name ?? m.id ?? JSON.stringify(m)),
209
+ );
210
+ if (labels.length > 0) console.log(` Model selectors: ${labels.join(", ")}`);
211
+ process.exit(0);
212
+ } catch (error) {
213
+ const message = redact(error.message);
214
+ if (/\b(404|405)\b/.test(message)) {
215
+ console.log("· This deployment does not serve /models, so the key could not be checked");
216
+ console.log(" without spending a detection call. The key was found and looks well formed.");
217
+ process.exit(0);
218
+ }
219
+ fail(1, message);
220
+ }
221
+ }
222
+
223
+ // ---- config ----------------------------------------------------------------
224
+
225
+ const target = positional[0];
226
+ if (!target) fail(2, `no file given.\n\n${HELP}`);
227
+ if (!existsSync(target)) fail(2, `no such file: ${target}`);
228
+
229
+ const CONFIG_NAME = ".pangram-check.json";
230
+ const KNOWN_KEYS = new Set([
231
+ "minWords",
232
+ "maxUnits",
233
+ "rejectTodo",
234
+ "rejectPatterns",
235
+ "skipQuotes",
236
+ "format",
237
+ "windows",
238
+ "model",
239
+ "cache",
240
+ ]);
241
+
242
+ function findConfig() {
243
+ if (flags.noConfig) return { path: null, values: {} };
244
+ if (flags.config) {
245
+ if (!existsSync(flags.config)) fail(2, `no such config file: ${flags.config}`);
246
+ return { path: flags.config, values: readConfig(flags.config) };
247
+ }
248
+ let dir = dirname(resolve(target));
249
+ for (;;) {
250
+ const candidate = resolve(dir, CONFIG_NAME);
251
+ if (existsSync(candidate)) return { path: candidate, values: readConfig(candidate) };
252
+ const parent = dirname(dir);
253
+ if (parent === dir) return { path: null, values: {} };
254
+ dir = parent;
255
+ }
256
+ }
257
+
258
+ function readConfig(path) {
259
+ let values;
260
+ try {
261
+ values = JSON.parse(readFileSync(path, "utf8"));
262
+ } catch (error) {
263
+ fail(2, `${path} is not valid JSON: ${error.message}`);
264
+ }
265
+ for (const key of Object.keys(values)) {
266
+ if (!KNOWN_KEYS.has(key)) console.error(`· ${path}: ignoring unknown key "${key}"`);
267
+ }
268
+ return values;
269
+ }
270
+
271
+ const config = findConfig();
272
+ const number = (value, name) => {
273
+ const parsed = Number(value);
274
+ if (!Number.isFinite(parsed) || parsed < 0) fail(2, `${name} must be a non-negative number`);
275
+ return parsed;
276
+ };
277
+
278
+ const settings = {
279
+ minWords:
280
+ flags.minWords !== undefined
281
+ ? number(flags.minWords, "--min-words")
282
+ : (config.values.minWords ?? 300),
283
+ maxUnits:
284
+ flags.maxUnits !== undefined
285
+ ? number(flags.maxUnits, "--max-units")
286
+ : (config.values.maxUnits ?? null),
287
+ rejectTodo: flags.rejectTodo || config.values.rejectTodo === true,
288
+ rejectPatterns: [...(config.values.rejectPatterns ?? []), ...flags.reject],
289
+ skipQuotes: flags.skipQuotes || config.values.skipQuotes === true,
290
+ format: flags.format ?? config.values.format ?? "text",
291
+ windows: flags.windows ?? String(config.values.windows ?? 5),
292
+ model: flags.model ?? config.values.model ?? null,
293
+ cache: flags.noCache ? false : (config.values.cache ?? true),
294
+ };
295
+
296
+ if (!["text", "markdown", "json"].includes(settings.format)) {
297
+ fail(2, `unknown format "${settings.format}". Use text, markdown or json.`);
298
+ }
299
+
300
+ // TODO markers are the reason this preset exists: a wrapped paragraph is prose
301
+ // that has not been written yet, so scoring it spends a paid call on a draft.
302
+ if (settings.rejectTodo) settings.rejectPatterns.unshift("(?:<!--\\s*|^\\s*)TODO\\b");
303
+
304
+ const detail = settings.windows === "all" ? Infinity : number(settings.windows, "--windows");
305
+
306
+ // ---- prose -----------------------------------------------------------------
307
+
308
+ const MARKDOWN = /\.(md|markdown|mdx|mdoc)$/i;
309
+ const isMarkdown = !flags.plain && MARKDOWN.test(target);
310
+ const source = readFileSync(target, "utf8");
311
+
312
+ const INLINE = [
313
+ [/!\[[^\]]*\]\([^)]*\)/g, ""], // images
314
+ [/!\[[^\]]*\]\[[^\]]*\]/g, ""],
315
+ [/\[([^\]]*)\]\([^)]*\)/g, "$1"], // links keep their text
316
+ [/\[([^\]]*)\]\[[^\]]*\]/g, "$1"],
317
+ [/\{\{[<%][\s\S]*?[%>]\}\}/g, ""], // Hugo shortcodes
318
+ [/\{%[\s\S]*?%\}/g, ""], // Liquid and Jekyll tags
319
+ [/`+([^`]*)`+/g, "$1"], // inline code keeps its words
320
+ [/<[^>\n]+>/g, ""], // HTML, JSX and autolinks
321
+ [/https?:\/\/\S+/g, ""],
322
+ [/\[\^[^\]]*\]/g, ""], // footnote references
323
+ [/\{#[^}]*\}/g, ""], // heading anchors
324
+ [/\*\*([^*]+)\*\*/g, "$1"],
325
+ [/(?<![\w*])\*([^*\n]+)\*(?![\w*])/g, "$1"],
326
+ [/~~([^~]+)~~/g, "$1"],
327
+ [/\\([*_`[\]#])/g, "$1"], // escapes
328
+ ];
329
+
330
+ function extract(text) {
331
+ const lines = text.split("\n");
332
+ const blocks = [];
333
+ let current = [];
334
+
335
+ const flush = () => {
336
+ if (current.length === 0) return;
337
+ const joined = current
338
+ .map((part) => part.text)
339
+ .join(" ")
340
+ .replace(/\s+/g, " ")
341
+ .trim();
342
+ if (joined) {
343
+ blocks.push({ text: /[.!?:]$/.test(joined) ? joined : `${joined}.`, line: current[0].line });
344
+ }
345
+ current = [];
346
+ };
347
+
348
+ let i = 0;
349
+ if (isMarkdown && (lines[0] === "---" || lines[0] === "+++")) {
350
+ const closer = lines[0];
351
+ for (i = 1; i < lines.length && lines[i].trimEnd() !== closer; i++);
352
+ i++;
353
+ }
354
+
355
+ let fence = null;
356
+ let comment = false;
357
+ let excluded = false;
358
+ let inList = false;
359
+ let inIndentedCode = false;
360
+ let prevBlank = true;
361
+
362
+ for (; i < lines.length; i++) {
363
+ const raw = lines[i];
364
+
365
+ if (/pangram-check:\s*off/.test(raw)) {
366
+ excluded = true;
367
+ flush();
368
+ continue;
369
+ }
370
+ if (/pangram-check:\s*on/.test(raw)) {
371
+ excluded = false;
372
+ continue;
373
+ }
374
+
375
+ if (isMarkdown && !comment) {
376
+ if (fence) {
377
+ if (new RegExp(`^ {0,3}\\${fence.char}{${fence.length},}\\s*$`).test(raw)) fence = null;
378
+ continue;
379
+ }
380
+ const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
381
+ if (opening) {
382
+ flush();
383
+ fence = { char: opening[1][0], length: opening[1].length };
384
+ continue;
385
+ }
386
+ }
387
+
388
+ let text = raw;
389
+ if (isMarkdown) {
390
+ const kept = [];
391
+ let rest = text;
392
+ for (;;) {
393
+ if (comment) {
394
+ const end = rest.indexOf("-->");
395
+ if (end === -1) break;
396
+ comment = false;
397
+ rest = rest.slice(end + 3);
398
+ continue;
399
+ }
400
+ const start = rest.indexOf("<!--");
401
+ if (start === -1) {
402
+ kept.push(rest);
403
+ break;
404
+ }
405
+ kept.push(rest.slice(0, start));
406
+ comment = true;
407
+ rest = rest.slice(start + 4);
408
+ }
409
+ text = kept.join("");
410
+ }
411
+
412
+ if (excluded) {
413
+ flush();
414
+ continue;
415
+ }
416
+
417
+ let line = text.trim();
418
+
419
+ if (!line) {
420
+ flush();
421
+ inList = false;
422
+ prevBlank = true;
423
+ continue;
424
+ }
425
+
426
+ if (isMarkdown) {
427
+ const next = lines[i + 1] ?? "";
428
+ // An indented block runs until the next blank line, which is where the
429
+ // blank branch above clears the flag.
430
+ inIndentedCode = /^ {4,}\S/.test(text) && (inIndentedCode || (prevBlank && !inList));
431
+ const drop =
432
+ /^#{1,6}\s/.test(line) || // ATX heading
433
+ /^ {0,3}=+\s*$/.test(text) || // setext underline
434
+ /^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(text) || // horizontal rule
435
+ /^\|/.test(line) || // table row
436
+ /^\[[^^\]]+\]:\s*\S/.test(line) || // link reference definition
437
+ inIndentedCode ||
438
+ /^ {0,3}(=+|-+)\s*$/.test(next); // this line is a setext heading
439
+
440
+ if (drop) {
441
+ flush();
442
+ prevBlank = false;
443
+ continue;
444
+ }
445
+
446
+ const item = /^ {0,8}([-*+]|\d+[.)])\s+/.exec(line);
447
+ if (item) {
448
+ flush();
449
+ line = line.slice(item[0].length).replace(/^\[[ xX]\]\s*/, "");
450
+ inList = true;
451
+ }
452
+
453
+ if (/^ {0,3}>/.test(line)) {
454
+ if (settings.skipQuotes) {
455
+ flush();
456
+ prevBlank = false;
457
+ continue;
458
+ }
459
+ line = line.replace(/^ {0,3}(>\s?)+/, "");
460
+ }
461
+
462
+ line = line.replace(/^\[\^[^\]]+\]:\s*/, ""); // footnote definition
463
+ }
464
+
465
+ prevBlank = false;
466
+
467
+ for (const [pattern, replacement] of INLINE) line = line.replace(pattern, replacement);
468
+ line = line.replace(/\s+/g, " ").trim();
469
+ if (line) current.push({ text: line, line: i + 1 });
470
+ }
471
+
472
+ flush();
473
+
474
+ let prose = "";
475
+ const map = [];
476
+ for (const block of blocks) {
477
+ if (prose) prose += " ";
478
+ const start = prose.length;
479
+ prose += block.text;
480
+ map.push({ start, end: prose.length, line: block.line });
481
+ }
482
+ return { prose, map };
483
+ }
484
+
485
+ const { prose, map } = extract(source);
486
+ const words = prose ? prose.split(" ").length : 0;
487
+ const units = Math.max(1, Math.ceil(words / 1000));
488
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
489
+ const lineFor = (index) => (map.find((b) => b.end > index) ?? map.at(-1))?.line ?? 1;
490
+
491
+ if (flags.printProse) {
492
+ process.stdout.write(`${prose}\n`);
493
+ process.exit(0);
494
+ }
495
+
496
+ // ---- guards ----------------------------------------------------------------
497
+
498
+ const rejections = [];
499
+ for (const pattern of settings.rejectPatterns) {
500
+ let regex;
501
+ try {
502
+ regex = new RegExp(pattern, "gm");
503
+ } catch (error) {
504
+ fail(2, `reject pattern ${pattern} is not a valid regular expression: ${error.message}`);
505
+ }
506
+ const lines = [];
507
+ for (const match of source.matchAll(regex)) {
508
+ lines.push(source.slice(0, match.index).split("\n").length);
509
+ }
510
+ if (lines.length > 0) rejections.push({ pattern, lines });
511
+ }
512
+
513
+ if (rejections.length > 0) {
514
+ console.error(`✖ ${target} matches a reject pattern. Nothing was sent.\n`);
515
+ for (const { pattern, lines } of rejections) {
516
+ const shown = lines.slice(0, 8).join(", ");
517
+ const more = lines.length > 8 ? `, and ${lines.length - 8} more` : "";
518
+ console.error(` ${lines.length} × /${pattern}/ on line ${shown}${more}`);
519
+ }
520
+ console.error(`
521
+ Every run costs money, so the document is checked once it is finished. Clear
522
+ the matches, or drop the pattern for this run${config.path ? " with --no-config." : "."}
523
+ `);
524
+ process.exit(1);
525
+ }
526
+
527
+ // Pangram's own documented floor. Below it the API declines to predict, so the
528
+ // call is spent for nothing. https://www.pangram.com/blog/why-does-pangram-have-a-minimum-word-count
529
+ const PANGRAM_FLOOR = 50;
530
+
531
+ if (words < PANGRAM_FLOOR) {
532
+ fail(
533
+ 1,
534
+ `${target} has ${words} words of prose. Pangram needs ${PANGRAM_FLOOR} to predict, so nothing was sent.`,
535
+ );
536
+ }
537
+ if (words < settings.minWords) {
538
+ console.error(
539
+ `✖ ${target} has ${words} words of prose, under the ${settings.minWords} word floor. Nothing was sent.`,
540
+ );
541
+ console.error(
542
+ ` Pangram predicts from ${PANGRAM_FLOOR} words up, with less confidence the shorter the text.`,
543
+ );
544
+ console.error(
545
+ ` Lower the floor with --min-words ${Math.max(PANGRAM_FLOOR, words)} to check it anyway.`,
546
+ );
547
+ process.exit(1);
548
+ }
549
+ if (settings.maxUnits !== null && units > settings.maxUnits) {
550
+ fail(
551
+ 1,
552
+ `${target} is ${words} words, an estimated ${plural(units, "billable unit")}, over the --max-units ${settings.maxUnits} ceiling. Nothing was sent.`,
553
+ );
554
+ }
555
+
556
+ // ---- cache -----------------------------------------------------------------
557
+
558
+ const cacheDir = resolve(
559
+ process.env.XDG_CACHE_HOME?.trim() || resolve(homedir(), ".cache"),
560
+ "pangram-check",
561
+ );
562
+ const digest = createHash("sha256")
563
+ .update(`${settings.model ?? "default"}\n${prose}`)
564
+ .digest("hex");
565
+ const cachePath = resolve(cacheDir, `${digest}.json`);
566
+
567
+ const readCache = () => {
568
+ if (!settings.cache || flags.refresh || !existsSync(cachePath)) return null;
569
+ try {
570
+ return JSON.parse(readFileSync(cachePath, "utf8"));
571
+ } catch {
572
+ return null;
573
+ }
574
+ };
575
+
576
+ const writeCache = (result) => {
577
+ if (!settings.cache) return;
578
+ try {
579
+ mkdirSync(cacheDir, { recursive: true });
580
+ writeFileSync(
581
+ cachePath,
582
+ JSON.stringify({ savedAt: new Date().toISOString(), result }, null, 2),
583
+ );
584
+ } catch {
585
+ // A cache that cannot be written changes nothing about the result.
586
+ }
587
+ };
588
+
589
+ // ---- run -------------------------------------------------------------------
590
+
591
+ const name = basename(target);
592
+ const context = {
593
+ file: target,
594
+ words,
595
+ units,
596
+ model: settings.model ?? "default",
597
+ config: config.path,
598
+ minWords: settings.minWords,
599
+ patterns: settings.rejectPatterns.length,
600
+ };
601
+
602
+ if (flags.dryRun) {
603
+ report(null, { ...context, dryRun: true, cached: Boolean(readCache()) });
604
+ process.exit(0);
605
+ }
606
+
607
+ const cached = readCache();
608
+ let result = cached?.result ?? null;
609
+
610
+ if (!result) {
611
+ key(); // Resolved before anything is announced, so a missing key reports first.
612
+ if (settings.format === "text") {
613
+ process.stderr.write(
614
+ ` Sending ${words} words to Pangram, an estimated ${plural(units, "billable unit")}…\n`,
615
+ );
616
+ }
617
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
618
+ try {
619
+ const body = { text: prose, public_dashboard_link: false };
620
+ if (settings.model) body.model = settings.model;
621
+ result = await call("/task", { method: "POST", body: JSON.stringify(body) });
622
+
623
+ for (let attempt = 0; result.stage !== "STAGE_SUCCESS" && attempt < 60; attempt++) {
624
+ if (result.stage === "STAGE_FAILED") {
625
+ throw new Error(
626
+ `Pangram reported STAGE_FAILED: ${redact(result.error ?? "no reason given")}`,
627
+ );
628
+ }
629
+ await sleep(attempt === 0 ? 1000 : 2000);
630
+ result = await call(`/task/${result.task_id}`, { method: "GET" });
631
+ }
632
+ if (result.stage !== "STAGE_SUCCESS")
633
+ throw new Error("Pangram did not finish within two minutes.");
634
+ } catch (error) {
635
+ fail(1, redact(error.message));
636
+ }
637
+ writeCache(result);
638
+ }
639
+
640
+ report(result, { ...context, cached: Boolean(cached), savedAt: cached?.savedAt ?? null });
641
+
642
+ // ---- reporting -------------------------------------------------------------
643
+
644
+ function windowsOf(result) {
645
+ return [...(result.windows ?? [])].map((w, index) => ({
646
+ index,
647
+ score: w.ai_assistance_score ?? 0,
648
+ label: w.label ?? "unknown",
649
+ confidence: w.confidence ?? "unknown",
650
+ words: w.word_count ?? null,
651
+ humanized: w.is_humanized ? (w.humanizer_score ?? null) : null,
652
+ line: lineFor(w.start_index ?? 0),
653
+ excerpt: (w.text ?? "").replace(/\s+/g, " ").trim(),
654
+ }));
655
+ }
656
+
657
+ function report(result, meta) {
658
+ if (settings.format === "json") return json(result, meta);
659
+ if (settings.format === "markdown") return markdown(result, meta);
660
+ return text(result, meta);
661
+ }
662
+
663
+ function pct(n) {
664
+ return `${Math.round((n ?? 0) * 100)}%`;
665
+ }
666
+
667
+ function json(result, meta) {
668
+ const payload = { ...meta, floor: PANGRAM_FLOOR };
669
+ if (result) {
670
+ payload.headline = result.headline ?? null;
671
+ payload.prediction = result.prediction ?? null;
672
+ payload.prediction_short = result.prediction_short ?? null;
673
+ payload.fractions = {
674
+ ai: result.fraction_ai ?? null,
675
+ ai_assisted: result.fraction_ai_assisted ?? null,
676
+ human: result.fraction_human ?? null,
677
+ };
678
+ payload.segments = {
679
+ ai: result.num_ai_segments ?? null,
680
+ ai_assisted: result.num_ai_assisted_segments ?? null,
681
+ human: result.num_human_segments ?? null,
682
+ };
683
+ payload.version = result.version ?? null;
684
+ payload.windows = windowsOf(result);
685
+ }
686
+ console.log(JSON.stringify(payload, null, 2));
687
+ }
688
+
689
+ function markdown(result, meta) {
690
+ const out = [];
691
+ out.push(`## Pangram check: \`${meta.file}\``);
692
+ out.push("");
693
+ if (!result) {
694
+ out.push(
695
+ `Dry run. ${meta.words} words of prose, an estimated ${plural(meta.units, "billable unit")}. Nothing was sent.`,
696
+ );
697
+ console.log(out.join("\n"));
698
+ return;
699
+ }
700
+ out.push(`**${result.headline ?? "No headline"}.** ${result.prediction ?? ""}`.trim());
701
+ out.push("");
702
+ out.push("| | |");
703
+ out.push("| --- | --- |");
704
+ out.push(`| Prose sent | ${meta.words} words, ${plural(meta.units, "billable unit")} |`);
705
+ out.push(
706
+ `| Fractions | ${pct(result.fraction_ai)} AI, ${pct(result.fraction_ai_assisted)} AI-assisted, ${pct(result.fraction_human)} human |`,
707
+ );
708
+ out.push(
709
+ `| Segments | ${result.num_ai_segments ?? 0} AI, ${result.num_ai_assisted_segments ?? 0} AI-assisted, ${result.num_human_segments ?? 0} human |`,
710
+ );
711
+ out.push(`| Model | ${meta.model} |`);
712
+ out.push(`| Source | ${meta.cached ? `cached result from ${meta.savedAt}` : "live call"} |`);
713
+ out.push("");
714
+
715
+ const windows = windowsOf(result).sort((a, b) => b.score - a.score);
716
+ if (windows.length > 0) {
717
+ out.push(`### ${plural(windows.length, "window")}, worst first`);
718
+ out.push("");
719
+ out.push("| Score | Label | Confidence | Words | Humanized | Location |");
720
+ out.push("| ----- | ----- | ---------- | ----- | --------- | -------- |");
721
+ for (const w of windows) {
722
+ out.push(
723
+ `| ${w.score.toFixed(2)} | ${w.label} | ${w.confidence} | ${w.words ?? "?"} | ${w.humanized === null ? "no" : w.humanized.toFixed(2)} | \`${name}:${w.line}\` |`,
724
+ );
725
+ }
726
+ out.push("");
727
+ for (const w of windows.slice(0, detail)) {
728
+ out.push(`**${w.score.toFixed(2)} at \`${name}:${w.line}\`**`);
729
+ out.push("");
730
+ out.push(`> ${w.excerpt.slice(0, 220)}…`);
731
+ out.push("");
732
+ }
733
+ }
734
+ for (const line of CAUTION) out.push(`${line}`);
735
+ console.log(out.join("\n"));
736
+ }
737
+
738
+ function text(result, meta) {
739
+ const colour = process.stdout.isTTY && !process.env.NO_COLOR && !flags.noColor;
740
+ const paint = (code, s) => (colour ? `\u001b[${code}m${s}\u001b[0m` : s);
741
+ const dim = (s) => paint(2, s);
742
+ const bold = (s) => paint(1, s);
743
+ const forScore = (score, s) => paint(score < 0.2 ? 32 : score < 0.6 ? 33 : 31, s);
744
+ const bar = (score) => {
745
+ const filled = Math.max(0, Math.min(10, Math.round(score * 10)));
746
+ return forScore(score, `${"█".repeat(filled)}${dim("░".repeat(10 - filled))}`);
747
+ };
748
+
749
+ console.log(bold(meta.file));
750
+ console.log(
751
+ ` ${meta.words} words of prose, an estimated ${plural(meta.units, "billable unit")}.`,
752
+ );
753
+ if (meta.patterns > 0)
754
+ console.log(` ${plural(meta.patterns, "reject pattern")} matched nothing.`);
755
+ if (meta.config) console.log(dim(` config: ${meta.config}`));
756
+
757
+ if (!result) {
758
+ console.log(
759
+ ` Dry run, so nothing was sent.${meta.cached ? " A cached result for this text exists." : ""}`,
760
+ );
761
+ return;
762
+ }
763
+
764
+ console.log("");
765
+ console.log(` ${bold(result.headline ?? "No headline")}`);
766
+ if (result.prediction) console.log(` ${result.prediction}`);
767
+ console.log(
768
+ ` ${pct(result.fraction_ai)} AI · ${pct(result.fraction_ai_assisted)} AI-assisted · ${pct(result.fraction_human)} human`,
769
+ );
770
+ console.log(
771
+ dim(
772
+ ` segments: ${result.num_ai_segments ?? 0} AI, ${result.num_ai_assisted_segments ?? 0} AI-assisted, ${result.num_human_segments ?? 0} human`,
773
+ ),
774
+ );
775
+ if (meta.cached) console.log(dim(` cached result from ${meta.savedAt}, no call was made`));
776
+
777
+ const windows = windowsOf(result);
778
+ if (windows.length > 2) {
779
+ const strip = windows
780
+ .map((w) => forScore(w.score, "▁▁▂▃▄▅▆▇█"[Math.round(w.score * 8)] || "▁"))
781
+ .join("");
782
+ console.log("");
783
+ console.log(` reading order ${strip} ${dim("one cell per window, start to end")}`);
784
+ }
785
+
786
+ const worst = [...windows].sort((a, b) => b.score - a.score);
787
+ if (worst.length > 0) {
788
+ const shown = worst.slice(0, detail);
789
+ console.log("");
790
+ const heading =
791
+ shown.length === windows.length
792
+ ? `${plural(windows.length, "window")}, worst first:`
793
+ : `${plural(windows.length, "window")}, worst ${shown.length} first:`;
794
+ console.log(` ${heading}`);
795
+ console.log("");
796
+ for (const w of shown) {
797
+ const flag = w.humanized === null ? "" : paint(35, ` humanized ${w.humanized.toFixed(2)}`);
798
+ console.log(
799
+ ` ${bar(w.score)} ${forScore(w.score, w.score.toFixed(2))} ${w.label}, ${w.confidence} confidence ${dim(`${name}:${w.line}`)}${flag}`,
800
+ );
801
+ console.log(dim(` "${w.excerpt.slice(0, 150)}…"`));
802
+ console.log("");
803
+ }
804
+ }
805
+
806
+ for (const line of CAUTION.slice(0, 2)) console.log(dim(` ${line}`));
807
+ }