@kensio/pangram-check 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,14 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
+ "name": "pangram-check",
4
+ "version": "1.13.1",
5
+ "description": "Check finished writing against the Pangram AI-text detector.",
6
+ "author": {
7
+ "name": "Kensio Software",
8
+ "email": "hugh@kensiosoftware.co.uk"
9
+ },
10
+ "homepage": "https://kensio.ai",
11
+ "repository": "https://github.com/KensioSoftware/kensio.ai",
12
+ "license": "Apache-2.0",
13
+ "keywords": ["writing", "pangram", "ai-detection", "prose", "markdown"]
14
+ }
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @kensio/pangram-check
2
+
3
+ Runs a finished document past [Pangram](https://www.pangram.com), a commercial AI-text detector, and
4
+ reports which passages read as machine-drafted. Packaged as an agent skill.
5
+
6
+ Only the prose is sent. The script strips frontmatter, code, HTML, shortcodes, tables and headings
7
+ first, then maps every result Pangram returns back to the source line it came from, so a flagged
8
+ passage is a `file:line` reference and not a paragraph to go hunting for.
9
+
10
+ Pangram is a paid service and the skill needs an API key. It runs when a user asks for it by name.
11
+
12
+ ## Install
13
+
14
+ Into any agent that reads `SKILL.md`:
15
+
16
+ ```bash
17
+ npx @kensio/skills add pangram-check
18
+ ```
19
+
20
+ That copies the skill directory into `.agents/skills/`, where Codex, Cursor, Copilot, Gemini CLI and
21
+ the other implementations of the specification look for one. Pass `--agent claude` for
22
+ `.claude/skills/`, `--agent copilot` for `.github/skills/`, and `--user` to install it for every
23
+ project at once.
24
+
25
+ Claude Code also takes it as a plugin:
26
+
27
+ ```bash
28
+ claude plugin marketplace add KensioSoftware/kensio.ai
29
+ claude plugin install pangram-check@kensio
30
+ ```
31
+
32
+ Or pin it in a repository as a dependency:
33
+
34
+ ```bash
35
+ npm install @kensio/pangram-check
36
+ ```
37
+
38
+ Every skill is also published as a zip on each
39
+ [release](https://github.com/KensioSoftware/kensio.ai/releases), for a machine with no npm reach.
40
+ Unzip it into `.agents/skills/` and it is installed.
41
+
42
+ ## Set up the key
43
+
44
+ Get one from the Pangram dashboard, then put it where every repository on the machine can reach it:
45
+
46
+ ```bash
47
+ mkdir -p ~/.config/pangram
48
+ printf 'PANGRAM_API_KEY=%s\n' 'the-key' > ~/.config/pangram/.env
49
+ chmod 600 ~/.config/pangram/.env
50
+ ```
51
+
52
+ Then check it. This hits `GET /models` and spends no detection call:
53
+
54
+ ```bash
55
+ node skills/pangram-check/scripts/pangram-check.mjs --check-key
56
+ ```
57
+
58
+ `$PANGRAM_API_KEY` and `$PANGRAM_ENV_FILE` both work too, and a `./.env` in the working directory is
59
+ the last place searched. The key is never printed, and it is redacted from any error text.
60
+
61
+ ## Use it
62
+
63
+ ```bash
64
+ node skills/pangram-check/scripts/pangram-check.mjs post.md
65
+ ```
66
+
67
+ ```
68
+ post.md
69
+ 742 words of prose, an estimated 1 billable unit.
70
+
71
+ AI Detected
72
+ This document was likely written with the help of a large language model.
73
+ 42% AI · 35% AI-assisted · 23% human
74
+ segments: 2 AI, 1 AI-assisted, 1 human
75
+
76
+ reading order ▁▂▇▄▁▁▃ one cell per window, start to end
77
+
78
+ 7 windows, worst 3 first:
79
+
80
+ █████████░ 0.91 AI, high confidence post.md:112 humanized 0.73
81
+ "the opening of the passage…"
82
+ ```
83
+
84
+ Scores are colour-coded on a terminal. `--format markdown` gives a table to paste into a report, and
85
+ `--format json` carries every field with the line references added.
86
+
87
+ ## What it will refuse to do
88
+
89
+ Every run costs money, so the script checks the document is worth a call before making one.
90
+
91
+ - **Under 50 words of prose:** Pangram's own
92
+ [documented floor](https://www.pangram.com/blog/why-does-pangram-have-a-minimum-word-count).
93
+ - **Under 300 words**, which is this skill's own default and is loosened with `--min-words`. Pangram
94
+ predicts from 50 words up, with less confidence the shorter the text.
95
+ - **Over a cost ceiling** set with `--max-units`. One billable unit is each started block of 1000
96
+ words.
97
+ - **Any pattern a repository has told it to refuse:** `--reject-todo` covers the common case, where
98
+ a `TODO` marker means a paragraph is still to be written. `--reject <regex>` takes anything else.
99
+
100
+ `--dry-run` runs all of that without sending the document. `--print-prose` prints the exact string
101
+ that would go out.
102
+
103
+ Results are cached by a hash of the extracted prose, so re-running an unchanged document is free.
104
+ There is deliberately no score history and no before-and-after delta, because both invite treating
105
+ the number as a target.
106
+
107
+ ## Configuration
108
+
109
+ A `.pangram-check.json` beside the document (or anywhere above it) sets the defaults for a
110
+ repository, and flags override it:
111
+
112
+ ```json
113
+ {
114
+ "minWords": 400,
115
+ "rejectTodo": true,
116
+ "format": "markdown",
117
+ "windows": 3
118
+ }
119
+ ```
120
+
121
+ Full flag list, extraction rules and cache locations are in
122
+ [`reference/configuration.md`](skills/pangram-check/reference/configuration.md). What each API field
123
+ means, and which signals to act on, are in
124
+ [`reference/reading-results.md`](skills/pangram-check/reference/reading-results.md).
125
+
126
+ ## What a score is for
127
+
128
+ A high score marks a passage worth rereading. It is not a number to drive down. Editing to move a
129
+ detector score is a different activity from writing in your own voice, and the two come apart
130
+ quickly.
131
+
132
+ Pangram reports whether text reads as machine-generated, and it has no opinion on whether the
133
+ writing is any good. Detectors also carry false positives, which published audits have found falling
134
+ hardest on writers working in a second language. One verdict is one input to a rereading, never
135
+ evidence about a person.
136
+
137
+ ## Related
138
+
139
+ [`@kensio/technical-prose-style`](https://www.npmjs.com/package/@kensio/technical-prose-style)
140
+ measures the constructions that make prose tiring to read. Its own measurements show text that
141
+ follows every one of its rules still being identified as machine-written, which is the clearest
142
+ demonstration that style and provenance are separate signals. Anyone after concealment is holding
143
+ the wrong tool.
144
+
145
+ Part of [kensio.ai](https://github.com/KensioSoftware/kensio.ai). Licensed under the Apache License
146
+ 2.0. See the [LICENSE](https://github.com/KensioSoftware/kensio.ai/blob/main/LICENSE) in the
147
+ repository root.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@kensio/pangram-check",
3
+ "version": "1.13.1",
4
+ "description": "Check finished writing against the Pangram AI-text detector.",
5
+ "keywords": [
6
+ "agent-skills",
7
+ "ai-detection",
8
+ "claude",
9
+ "claude-code",
10
+ "claude-code-plugin",
11
+ "codex",
12
+ "copilot",
13
+ "cursor",
14
+ "kensio",
15
+ "markdown",
16
+ "pangram",
17
+ "prose",
18
+ "skill",
19
+ "skill-md",
20
+ "writing"
21
+ ],
22
+ "homepage": "https://kensio.ai",
23
+ "license": "Apache-2.0",
24
+ "author": "Kensio Software <hugh@kensiosoftware.co.uk>",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/KensioSoftware/kensio.ai.git",
28
+ "directory": "plugins/pangram-check"
29
+ },
30
+ "files": [
31
+ ".claude-plugin",
32
+ "skills",
33
+ "README.md"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: pangram-check
3
+ description: Send the prose of a finished document to Pangram, a commercial AI-text detector, and report which passages read as machine-drafted, with the source line each one starts on. Use when the user asks to "check this with Pangram", "run the AI detector over this", "see if this still reads as AI", or wants a final sweep after rewriting a draft. Every run is paid and sends the writing to a third party, so this skill is invoked by name.
4
+ license: Apache-2.0
5
+ compatibility: Needs a Pangram API key and network access. Every run is billed.
6
+ metadata:
7
+ version: "1.13.1"
8
+ disable-model-invocation: true
9
+ ---
10
+
11
+ # Pangram check
12
+
13
+ A final sweep over a finished document. Pangram scores the text in windows of a few hundred words
14
+ and reports which parts read as machine-drafted, so a long document gets a passage-by-passage
15
+ answer.
16
+
17
+ ```bash
18
+ node scripts/pangram-check.mjs path/to/file.md
19
+ ```
20
+
21
+ Full flag list and config file in [references/configuration.md](references/configuration.md). Field
22
+ meanings and how to read a verdict in
23
+ [references/reading-results.md](references/reading-results.md).
24
+
25
+ ## Confirm before spending a call
26
+
27
+ Every run costs the user money and sends their writing to a third party. Before the first call of a
28
+ session:
29
+
30
+ 1. Name the file that is about to go out.
31
+ 2. Give the word count and the billable estimate. `--dry-run` prints both and sends nothing.
32
+ 3. Wait for a yes.
33
+
34
+ This skill sets `disable-model-invocation: true`. It runs when a user asks for it by name, and never
35
+ as a step some other task decided to take. A skill that drafts or rewrites text may mention it and
36
+ stop there.
37
+
38
+ ## Check the key first
39
+
40
+ On a machine that has not run this before:
41
+
42
+ ```bash
43
+ node scripts/pangram-check.mjs --check-key
44
+ ```
45
+
46
+ That checks the key against `GET /models` and spends no detection call. With no key found, the
47
+ script prints where it looked and how to store one. Pass that message on as it stands. Never print,
48
+ echo or paste the key itself, and never put it in a shell command the user will see.
49
+
50
+ The key is read from `$PANGRAM_API_KEY`, then `$PANGRAM_ENV_FILE`, then `~/.config/pangram/.env`,
51
+ then `./.env`. Recommend the third one, which every repository on the machine can reach.
52
+
53
+ ## Only the prose is sent
54
+
55
+ The document is reduced to its prose before the call, and that reduced text is what Pangram
56
+ receives. Frontmatter, code blocks, HTML comments, images, shortcodes, raw HTML, tables, headings
57
+ and URLs are all removed. Link text and inline code keep their words.
58
+
59
+ Check what would go out with `--print-prose`. The word count the script reports is that same string,
60
+ so a document reporting `512 words of prose` sent exactly those 512 words whatever the file length.
61
+
62
+ This matters for reading the verdict. A page that is mostly code would otherwise have its bash and
63
+ JSON scored alongside the writing, which pulls the fractions towards human for reasons unrelated to
64
+ how the prose reads.
65
+
66
+ Wrap quoted material in `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` to keep someone
67
+ else's writing out of the score.
68
+
69
+ ## The guards
70
+
71
+ Two of them refuse to send anything.
72
+
73
+ - **Under 50 words of prose:** Pangram's own documented floor, below which it declines to predict.
74
+ - **Under `--min-words`** (300 by default). Pangram predicts from 50 words up with less confidence
75
+ the shorter the text. Lower the floor for a short document, and treat the verdict as weaker.
76
+
77
+ A repository can add its own with `rejectPatterns` in `.pangram-check.json`, or `--reject <regex>`
78
+ for one run. `--reject-todo` is the preset for the common case, where a `TODO` marker means a
79
+ paragraph is still to be written and scoring it would spend a paid call on a draft.
80
+
81
+ `--max-units <n>` refuses a document over a cost ceiling. One billable unit is each started block of
82
+ 1000 words.
83
+
84
+ ## Repeat runs are free
85
+
86
+ Results are cached by a hash of the extracted prose, so running the same unchanged document again
87
+ reads the cache and calls nothing. Edit the prose and the next run is a fresh call. `--refresh`
88
+ forces one, `--no-cache` skips the cache in both directions.
89
+
90
+ There is deliberately no score history and no before-and-after delta. Both would invite treating the
91
+ number as a target.
92
+
93
+ ## Output
94
+
95
+ `--format text` (the default) colour-codes scores on a terminal and draws the windows in reading
96
+ order. `--format markdown` gives a table to paste into a report. `--format json` carries every field
97
+ plus the source line for each window.
98
+
99
+ ## Reporting back
100
+
101
+ Give the user the headline, the three fractions, and the worst two or three windows with their
102
+ scores and line references. Point at the passages by line so they can open them.
103
+
104
+ Leave the judgement about rewriting to the user. A high score marks a passage worth rereading, and
105
+ it is not a number to drive down. Editing to move a detector score is a different activity from
106
+ writing in your own voice, and the two come apart quickly.
107
+
108
+ ## Limits
109
+
110
+ Pangram reports whether text reads as machine-generated. It has no opinion on whether the writing is
111
+ any good, and a document can come back fully human while being badly organised.
112
+
113
+ A `is_humanized` flag on a window is a stronger signal than a high score by itself, because it means
114
+ the passage looks like text that has been worked over to read as human.
115
+
116
+ Detectors carry false positives, which fall hardest on writers working in a second language. Treat
117
+ one verdict as one input to a rereading, never as proof of authorship, and say so if a user starts
118
+ treating it as proof.
119
+
120
+ ## Related skills
121
+
122
+ `technical-prose-style` covers a different question. It measures the constructions that make prose
123
+ tiring to read, and its own measurements show that text following every one of its rules is still
124
+ identified as machine-written. Style and provenance are separate signals. Anyone after concealment
125
+ is holding the wrong tool.
@@ -0,0 +1,120 @@
1
+ # Configuration
2
+
3
+ Every setting has a flag. The ones a repository wants every time belong in a config file.
4
+
5
+ ## Flags
6
+
7
+ | Flag | Effect |
8
+ | ------------------ | ---------------------------------------------------------------- |
9
+ | `--dry-run` | Runs the guards, prints the cost estimate, sends nothing |
10
+ | `--print-prose` | Prints the extracted prose and exits |
11
+ | `--format <f>` | `text` (default), `markdown` or `json` |
12
+ | `--windows <n>` | How many windows to detail, or `all` (default 5) |
13
+ | `--min-words <n>` | Refuses below this many words of prose (default 300) |
14
+ | `--max-units <n>` | Refuses over this many billable units |
15
+ | `--reject-todo` | Refuses while the file holds `TODO` markers |
16
+ | `--reject <regex>` | Refuses when the pattern matches the file. Repeatable |
17
+ | `--skip-quotes` | Leaves blockquoted material out of the prose |
18
+ | `--plain` | Treats the file as plain text, skipping the markdown rules |
19
+ | `--model <name>` | Pangram model selector, passed through as `model` |
20
+ | `--list-models` | Lists the selectors the key allows, then exits |
21
+ | `--check-key` | Checks the key is accepted, then exits. Spends no detection call |
22
+ | `--refresh` | Ignores any cached result for this text |
23
+ | `--no-cache` | Neither reads nor writes the cache |
24
+ | `--config <path>` | Uses this config file |
25
+ | `--no-config` | Ignores any `.pangram-check.json` |
26
+ | `--no-color` | Plain output on a terminal |
27
+
28
+ Exit codes: `0` ran, `1` a guard refused or Pangram failed, `2` a usage, config or key problem.
29
+
30
+ ## The config file
31
+
32
+ `.pangram-check.json`, found by walking up from the file being checked. The first one found wins,
33
+ and flags override it. An unknown key prints a warning and is ignored.
34
+
35
+ ```json
36
+ {
37
+ "minWords": 300,
38
+ "maxUnits": 2,
39
+ "rejectTodo": true,
40
+ "rejectPatterns": ["<!--\\s*FIXME", "^\\s*XXX\\b"],
41
+ "skipQuotes": false,
42
+ "format": "markdown",
43
+ "windows": 3,
44
+ "model": "default",
45
+ "cache": true
46
+ }
47
+ ```
48
+
49
+ Patterns are JavaScript regular expressions, matched against the raw file with the `g` and `m`
50
+ flags, so `^` and `$` anchor to a line. Backslashes need escaping for JSON. Every match is reported
51
+ with its line number.
52
+
53
+ `rejectTodo` is the preset behind `--reject-todo`, and matches a `TODO` opening an HTML comment or a
54
+ line.
55
+
56
+ ## The API key
57
+
58
+ Read from the first of these that carries `PANGRAM_API_KEY`, and never printed:
59
+
60
+ 1. `$PANGRAM_API_KEY` in the environment
61
+ 2. `$PANGRAM_ENV_FILE`, a path to a dotenv file
62
+ 3. `~/.config/pangram/.env` (or `$XDG_CONFIG_HOME/pangram/.env`)
63
+ 4. `./.env` in the working directory
64
+
65
+ The third is the one to recommend. It sits outside any repository and every project on the machine
66
+ can reach it.
67
+
68
+ ```bash
69
+ mkdir -p ~/.config/pangram
70
+ printf 'PANGRAM_API_KEY=%s\n' 'the-key' > ~/.config/pangram/.env
71
+ chmod 600 ~/.config/pangram/.env
72
+ ```
73
+
74
+ A placeholder value (anything starting `replace`, `your` or `<`) counts as absent. The key is
75
+ redacted from any error text the script prints.
76
+
77
+ `$PANGRAM_API_BASE` overrides the API host. The test stub in this repository uses it.
78
+
79
+ ## What counts as prose
80
+
81
+ These are removed entirely:
82
+
83
+ - YAML and TOML frontmatter
84
+ - Fenced and indented code blocks
85
+ - HTML comments
86
+ - Images, tables, headings and horizontal rules
87
+ - Link reference definitions
88
+ - Hugo shortcodes and Liquid tags
89
+ - HTML and JSX tags
90
+ - Bare URLs
91
+
92
+ These keep their words and lose their markup:
93
+
94
+ - Link text
95
+ - Inline code
96
+ - Bold and emphasis
97
+ - Footnote text
98
+ - List item text
99
+ - Blockquotes (unless `--skip-quotes`)
100
+
101
+ Each paragraph and each list item becomes one block. Blocks are joined with a space, and a block
102
+ with no terminal punctuation gains a full stop. Without it the detector reads a run of list items as
103
+ one sentence.
104
+
105
+ Regions between `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` are dropped. Use them
106
+ for quoted material.
107
+
108
+ `.md`, `.markdown`, `.mdx` and `.mdoc` get the markdown rules. Everything else is treated as plain
109
+ text, and `--plain` forces that.
110
+
111
+ Every block records the source line it started on. That is where the `file:line` reference on each
112
+ reported window comes from.
113
+
114
+ ## The cache
115
+
116
+ `$XDG_CACHE_HOME/pangram-check/` or `~/.cache/pangram-check/`, one JSON file per result, keyed by a
117
+ SHA-256 of the model selector and the extracted prose. The file holds the full API response,
118
+ including the submitted text.
119
+
120
+ Delete the directory to clear it. `--no-cache` writes nothing there.
@@ -0,0 +1,69 @@
1
+ # Reading a result
2
+
3
+ Pangram scores the whole submission and then scores it again in windows, so the useful part of a
4
+ result is the window list.
5
+
6
+ ## The document level
7
+
8
+ | Field | What it carries |
9
+ | -------------------------- | --------------------------------------------------------- |
10
+ | `headline` | A short verdict, such as `AI Detected` or `Human Written` |
11
+ | `prediction` | A sentence of detail behind the headline |
12
+ | `prediction_short` | The same in a word or two |
13
+ | `fraction_ai` | Share of the text scored as machine-written, 0 to 1 |
14
+ | `fraction_ai_assisted` | Share scored as written with machine help |
15
+ | `fraction_human` | Share scored as human-written |
16
+ | `num_ai_segments` | Count of segments in each class, alongside the two below |
17
+ | `num_ai_assisted_segments` | |
18
+ | `num_human_segments` | |
19
+ | `version` | The detector version behind the verdict |
20
+
21
+ The three fractions and the three segment counts answer different questions. A document can be 20%
22
+ AI by volume while only one segment out of nine is the source of it, which points at a passage. The
23
+ same 20% spread evenly across every segment points at the whole document.
24
+
25
+ ## The window level
26
+
27
+ | Field | What it carries |
28
+ | ---------------------------- | ------------------------------------------------- |
29
+ | `ai_assistance_score` | 0 for human, 1 for machine. The number to sort by |
30
+ | `label` | `Human`, `AI` or `AI Assisted` |
31
+ | `confidence` | How sure the detector is of that label |
32
+ | `text` | The passage that was scored |
33
+ | `start_index`, `end_index` | Character offsets into the submitted prose |
34
+ | `word_count`, `token_length` | The size of the window |
35
+ | `is_humanized` | The passage looks worked over to read as human |
36
+ | `humanizer_score` | How strongly, 0 to 1 |
37
+
38
+ The script turns `start_index` into a `file:line` reference by mapping it back through the
39
+ extraction, so a flagged window points at the paragraph in the source file. Line references are
40
+ accurate to the block, and the column is not tracked.
41
+
42
+ ## Which signals to act on
43
+
44
+ **Sort by `ai_assistance_score` and read the top two or three passages.** Everything else in the
45
+ result is context for those.
46
+
47
+ **`is_humanized` outranks a high score.** A high score says a passage reads as machine-written. The
48
+ humanized flag says it reads as machine-written text that has since been edited to look human, which
49
+ is a claim about the editing as well as the drafting.
50
+
51
+ **`confidence` qualifies the label, and it qualifies a low score too.** A `Human` label at low
52
+ confidence is a weaker result than a `Human` label at high confidence.
53
+
54
+ **Short windows are noisier.** A window of 60 words carries less signal than one of 300. Check
55
+ `word_count` before acting on an outlier.
56
+
57
+ ## The limits of a verdict
58
+
59
+ Pangram reports whether text reads as machine-generated. Whether the writing is any good is a
60
+ separate question, and a document can come back fully human while being badly organised, wrong, or
61
+ dull.
62
+
63
+ Detectors carry false positives. Published audits have found high false-positive rates on writing by
64
+ non-native English speakers, which is the failure mode to hold in mind before treating a score as
65
+ evidence about a person. One verdict is one input to a rereading.
66
+
67
+ Scores also move for reasons that have nothing to do with quality. Quoted material, standard
68
+ technical phrasing and heavily edited passages all shift the number. Read the passage the score
69
+ points at, and judge the passage.
@@ -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
+ }