@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.
- package/README.md +48 -0
- package/bin/kensio-skills.mjs +213 -0
- package/package.json +39 -0
- package/skills/dynamodb-single-table/SKILL.md +329 -0
- package/skills/dynamodb-single-table/references/aws-guidance.md +183 -0
- package/skills/github-issue-drafting/SKILL.md +260 -0
- package/skills/isolated-testing-style/SKILL.md +293 -0
- package/skills/pangram-check/SKILL.md +125 -0
- package/skills/pangram-check/references/configuration.md +120 -0
- package/skills/pangram-check/references/reading-results.md +69 -0
- package/skills/pangram-check/scripts/pangram-check.mjs +807 -0
- package/skills/part-factory-test-data/SKILL.md +238 -0
- package/skills/skill-template/SKILL.md +126 -0
- package/skills/technical-prose-style/SKILL.md +356 -0
- package/skills/technical-prose-style/references/measurements.md +356 -0
- package/skills/technical-prose-style/scripts/prose-check.mjs +381 -0
- package/skills/yulin-aws-simulation/SKILL.md +324 -0
|
@@ -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
|
+
}
|