patina-cli 3.11.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
package/src/providers.js CHANGED
@@ -1,15 +1,24 @@
1
+ // @ts-check
1
2
  // Provider presets: shortcuts for common OpenAI-compatible endpoints.
2
3
  // Each provider maps to a base URL + a recommended default model + the env
3
4
  // variable users typically set to authenticate. Selecting a provider is
4
5
  // equivalent to manually setting --base-url, --model, and the right key.
5
6
  import { inputError } from './errors.js';
7
+ import { DEFAULT_BEST_MODELS } from './model-defaults.js';
6
8
 
9
+ /**
10
+ * Built-in OpenAI-compatible provider presets.
11
+ *
12
+ * @type {Record<string, {name: string, baseURL: string, apiKeyEnv: string, defaultModel: string, freeTier: boolean, note: string}>}
13
+ * @example
14
+ * const openaiBaseURL = PROVIDERS.openai.baseURL;
15
+ */
7
16
  export const PROVIDERS = {
8
17
  openai: {
9
18
  name: 'openai',
10
19
  baseURL: 'https://api.openai.com/v1',
11
20
  apiKeyEnv: 'OPENAI_API_KEY',
12
- defaultModel: 'gpt-4o',
21
+ defaultModel: DEFAULT_BEST_MODELS.openai,
13
22
  freeTier: false,
14
23
  note: 'Paid. Default OpenAI Platform API.',
15
24
  },
@@ -17,7 +26,7 @@ export const PROVIDERS = {
17
26
  name: 'gemini',
18
27
  baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
19
28
  apiKeyEnv: 'GEMINI_API_KEY',
20
- defaultModel: 'gemini-1.5-flash',
29
+ defaultModel: DEFAULT_BEST_MODELS.geminiCli,
21
30
  freeTier: true,
22
31
  note: 'Free tier available. Get a key at https://aistudio.google.com/app/apikey',
23
32
  },
@@ -29,6 +38,22 @@ export const PROVIDERS = {
29
38
  freeTier: true,
30
39
  note: 'Free tier with rate limits. Get a key at https://console.groq.com/keys',
31
40
  },
41
+ kimi: {
42
+ name: 'kimi',
43
+ baseURL: 'https://api.moonshot.ai/v1',
44
+ apiKeyEnv: 'KIMI_API_KEY',
45
+ defaultModel: 'kimi-k2.5',
46
+ freeTier: false,
47
+ note: 'Moonshot AI Kimi OpenAI-compatible API. Set KIMI_API_KEY or PATINA_API_KEY.',
48
+ },
49
+ moonshot: {
50
+ name: 'moonshot',
51
+ baseURL: 'https://api.moonshot.ai/v1',
52
+ apiKeyEnv: 'MOONSHOT_API_KEY',
53
+ defaultModel: 'kimi-k2.5',
54
+ freeTier: false,
55
+ note: 'Moonshot AI Kimi OpenAI-compatible API. Set MOONSHOT_API_KEY or PATINA_API_KEY.',
56
+ },
32
57
  together: {
33
58
  name: 'together',
34
59
  baseURL: 'https://api.together.xyz/v1',
@@ -39,6 +64,15 @@ export const PROVIDERS = {
39
64
  },
40
65
  };
41
66
 
67
+ /**
68
+ * Resolve a provider preset by name.
69
+ *
70
+ * @param {string|null|undefined} name Provider name; falsy returns null.
71
+ * @returns {object|null} Provider preset or null.
72
+ * @throws {PatinaCliError} When name is unknown.
73
+ * @example
74
+ * const provider = selectProvider('openai');
75
+ */
42
76
  export function selectProvider(name) {
43
77
  if (!name) return null;
44
78
  const provider = PROVIDERS[name];
@@ -46,12 +80,25 @@ export function selectProvider(name) {
46
80
  throw inputError(
47
81
  `Unknown provider: ${name}`,
48
82
  `Available providers are: ${Object.keys(PROVIDERS).join(', ')}.`,
49
- 'Run `patina --list-providers` to inspect provider presets.'
83
+ 'Run `patina --help` to see provider presets.'
50
84
  );
51
85
  }
52
86
  return provider;
53
87
  }
54
88
 
89
+ /**
90
+ * Resolve effective API key, base URL, and model from explicit values, provider, and env.
91
+ *
92
+ * @param {object} options Provider resolution inputs.
93
+ * @param {object|null} [options.provider] Provider preset from {@link selectProvider}.
94
+ * @param {string} [options.apiKey] Explicit API key.
95
+ * @param {string} [options.baseURL] Explicit base URL.
96
+ * @param {string} [options.model] Explicit model id.
97
+ * @returns {{apiKey: string|null, baseURL: string, model: string, apiKeySource: string|null, baseURLSource: string|null, modelSource: string|null}} Resolved provider config.
98
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
99
+ * @example
100
+ * const resolved = resolveProviderConfig({ provider: selectProvider('openai') });
101
+ */
55
102
  export function resolveProviderConfig({ provider, apiKey, baseURL, model }) {
56
103
  // Explicit args win. Then provider preset. Then PATINA_* env vars.
57
104
  // Returns the resolved { apiKey, baseURL, model } and the source for each
@@ -92,7 +139,7 @@ export function resolveProviderConfig({ provider, apiKey, baseURL, model }) {
92
139
  resolved.baseURLSource = process.env.PATINA_API_BASE ? 'env:PATINA_API_BASE' : 'default';
93
140
  }
94
141
  if (!resolved.model) {
95
- resolved.model = process.env.PATINA_MODEL || 'gpt-4o';
142
+ resolved.model = process.env.PATINA_MODEL || DEFAULT_BEST_MODELS.openai;
96
143
  resolved.modelSource = process.env.PATINA_MODEL ? 'env:PATINA_MODEL' : 'default';
97
144
  }
98
145
 
package/src/scoring.js CHANGED
@@ -1,10 +1,33 @@
1
+ // @ts-check
1
2
  import { callLLM as defaultCallLLM } from './api.js';
2
3
  import { getRepoRoot } from './config.js';
3
4
  import { analyzeText } from './features/index.js';
5
+ import { summarizeSignalStrength } from './features/signal-strength.js';
4
6
  import { createLogger } from './logger.js';
5
7
 
8
+ /**
9
+ * Default maximum delta before deterministic and LLM scores are reconciled upward.
10
+ *
11
+ * @type {number}
12
+ * @example
13
+ * const threshold = DEFAULT_DETERMINISTIC_DIVERGENCE_THRESHOLD;
14
+ */
6
15
  export const DEFAULT_DETERMINISTIC_DIVERGENCE_THRESHOLD = 20;
7
16
 
17
+ /**
18
+ * Score floor applied when deterministic markup-leakage is detected.
19
+ *
20
+ * Model-output leakage (issue #332) is near-proof-grade: a single token that
21
+ * LLM tooling injects and humans never type. Unlike the stylometric/lexical
22
+ * signals it is decisive on its own, so any hit short-circuits the deterministic
23
+ * `overall` into the 'heavily AI' band (>70) regardless of the per-paragraph
24
+ * hot ratio. It is a floor, not a hard 100, because the surrounding prose may
25
+ * still be genuinely human and we avoid claiming absolute proof.
26
+ *
27
+ * @type {number}
28
+ */
29
+ export const LEAKAGE_SCORE_FLOOR = 90;
30
+
8
31
  class SchemaError extends Error {
9
32
  constructor(message, raw) {
10
33
  super(message);
@@ -43,7 +66,8 @@ async function callAndParseJson({
43
66
  temperature = 0.1,
44
67
  deadline,
45
68
  signal,
46
- callLLM = defaultCallLLM,
69
+ timeout,
70
+ callLLM = /** @type {Function} */ (defaultCallLLM),
47
71
  logger = createLogger(),
48
72
  now,
49
73
  sleep,
@@ -59,6 +83,7 @@ async function callAndParseJson({
59
83
  temperature: t,
60
84
  deadline,
61
85
  signal,
86
+ timeout,
62
87
  now,
63
88
  sleep,
64
89
  });
@@ -76,6 +101,28 @@ async function callAndParseJson({
76
101
  throw lastError;
77
102
  }
78
103
 
104
+ /**
105
+ * Score text for AI-likeness using an LLM JSON scorer plus deterministic shadow signals.
106
+ *
107
+ * @param {object} options Scoring options.
108
+ * @param {string} options.text Text to score.
109
+ * @param {object} options.config Effective patina config.
110
+ * @param {object[]} options.patterns Loaded pattern packs, retained for scorer compatibility.
111
+ * @param {string} [options.apiKey] Provider API key.
112
+ * @param {string} [options.baseURL] Provider base URL.
113
+ * @param {string} [options.model] Model id.
114
+ * @param {number} [options.deadline] Absolute epoch-millisecond deadline.
115
+ * @param {AbortSignal} [options.signal] External cancellation signal.
116
+ * @param {number} [options.timeout] Per-attempt backend timeout in milliseconds.
117
+ * @param {Function} [options.callLLM] Injectable LLM implementation.
118
+ * @param {object} [options.logger] patina logger.
119
+ * @param {Function} [options.now] Clock returning epoch milliseconds.
120
+ * @param {Function} [options.sleep] Sleep helper for tests.
121
+ * @returns {Promise<object>} Score payload with overall, interpretation, llmScore, and deterministicScore.
122
+ * @throws {Error} When the operation is aborted.
123
+ * @example
124
+ * const score = await scoreText({ text: 'Draft', config, patterns, callLLM: async () => '{"categories":{},"overall":20,"interpretation":"mostly human"}' });
125
+ */
79
126
  export async function scoreText({
80
127
  text,
81
128
  config,
@@ -85,6 +132,7 @@ export async function scoreText({
85
132
  model,
86
133
  deadline,
87
134
  signal,
135
+ timeout,
88
136
  callLLM = defaultCallLLM,
89
137
  logger = createLogger(),
90
138
  now,
@@ -133,6 +181,7 @@ ${text}
133
181
  deadline,
134
182
  signal,
135
183
  callLLM,
184
+ timeout,
136
185
  logger,
137
186
  now,
138
187
  sleep,
@@ -153,6 +202,19 @@ ${text}
153
202
  }
154
203
  }
155
204
 
205
+ /**
206
+ * Compute deterministic stylometry/lexicon AI-likeness signals.
207
+ *
208
+ * @param {object} [options] Deterministic scoring options.
209
+ * @param {string} [options.text] Text to analyze.
210
+ * @param {object} [options.config={}] Effective config.
211
+ * @param {string} [options.repoRoot] Repository root for analyzer resources.
212
+ * @param {Function} [options.analyzer] Analyzer implementation.
213
+ * @returns {object|null} Deterministic score payload, skipped payload, or null when disabled.
214
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
215
+ * @example
216
+ * const deterministic = scoreDeterministicSignals({ text: 'Draft', config });
217
+ */
156
218
  export function scoreDeterministicSignals({
157
219
  text,
158
220
  config = {},
@@ -172,23 +234,37 @@ export function scoreDeterministicSignals({
172
234
  skipReason: 'language-disabled',
173
235
  paragraphCount: 0,
174
236
  hotParagraphs: 0,
237
+ signalScore: 0,
175
238
  bands: emptyDeterministicBands(),
176
239
  };
177
240
  }
178
241
 
179
242
  try {
243
+ const lexiconAllowed = isLexiconEnabledForLanguage(config, lang);
180
244
  const result = analyzer(String(text || ''), {
181
245
  lang,
182
246
  repoRoot,
183
247
  burstinessBands: config.stylometry?.burstiness?.bands,
184
248
  mattrBands: config.stylometry?.ttr?.bands,
185
249
  mattrWindow: config.stylometry?.ttr?.window,
250
+ koDiagnosticsEnabled: config.stylometry?.ko_diagnostics?.enabled !== false,
251
+ koDiagnosticBands: config.stylometry?.ko_diagnostics?.bands,
186
252
  lexiconDensityThreshold: config.lexicon?.density_threshold,
253
+ ...(lexiconAllowed ? {} : { lexicon: { lang, path: null, strict: [], phrases: [] } }),
187
254
  });
188
255
  const paragraphs = Array.isArray(result?.paragraphs) ? result.paragraphs : [];
189
256
  const paragraphCount = paragraphs.length;
190
257
  const hotParagraphs = paragraphs.filter((p) => p.hot).length;
191
- const overall = paragraphCount > 0 ? roundScore((hotParagraphs / paragraphCount) * 100) : 0;
258
+ const hotRatioOverall = paragraphCount > 0 ? roundScore((hotParagraphs / paragraphCount) * 100) : 0;
259
+ // Model-output leakage (#332) is near-proof-grade and lives at the document
260
+ // level, so it short-circuits the hot-ratio score into the 'heavily AI' band.
261
+ const leaked = Boolean(result?.markupLeakage?.leaked);
262
+ const overall = leaked ? Math.max(hotRatioOverall, LEAKAGE_SCORE_FLOOR) : hotRatioOverall;
263
+ const signalScore = roundScore(summarizeSignalStrength(paragraphs, {
264
+ burstinessBands: config.stylometry?.burstiness?.bands,
265
+ mattrBands: config.stylometry?.ttr?.bands,
266
+ lexiconDensityThreshold: config.lexicon?.density_threshold,
267
+ }));
192
268
 
193
269
  return {
194
270
  overall,
@@ -197,6 +273,7 @@ export function scoreDeterministicSignals({
197
273
  skipReason: result?.skipReason ?? null,
198
274
  paragraphCount,
199
275
  hotParagraphs,
276
+ signalScore,
200
277
  bands: {
201
278
  burstiness: countBands(paragraphs.map((p) => p.burstiness?.band)),
202
279
  mattr: countBands(paragraphs.map((p) => p.mattr?.band)),
@@ -204,6 +281,15 @@ export function scoreDeterministicSignals({
204
281
  hot: paragraphs.filter((p) => p.lexicon?.hot).length,
205
282
  threshold: config.lexicon?.density_threshold ?? null,
206
283
  },
284
+ koDiagnostics: {
285
+ hot: paragraphs.filter((p) => p.koDiagnostics?.hot).length,
286
+ thresholds: config.stylometry?.ko_diagnostics?.bands ?? null,
287
+ },
288
+ markupLeakage: {
289
+ leaked,
290
+ hits: Array.isArray(result?.markupLeakage?.hits) ? result.markupLeakage.hits.length : 0,
291
+ floor: LEAKAGE_SCORE_FLOOR,
292
+ },
207
293
  },
208
294
  };
209
295
  } catch (err) {
@@ -214,12 +300,26 @@ export function scoreDeterministicSignals({
214
300
  skipReason: 'deterministic-failure',
215
301
  paragraphCount: 0,
216
302
  hotParagraphs: 0,
303
+ signalScore: 0,
217
304
  bands: emptyDeterministicBands(),
218
305
  error: err?.message || 'deterministic scoring failed',
219
306
  };
220
307
  }
221
308
  }
222
309
 
310
+ /**
311
+ * Merge an LLM score payload with deterministic shadow-score reconciliation.
312
+ *
313
+ * @param {object} parsed Parsed LLM scoring JSON.
314
+ * @param {object} [options] Reconciliation options.
315
+ * @param {object|null} [options.deterministicScore] Deterministic score payload.
316
+ * @param {object} [options.config={}] Effective config.
317
+ * @param {object} [options.logger] Logger for reconciliation warnings.
318
+ * @returns {object} Score payload preserving llmScore and deterministicScore details.
319
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
320
+ * @example
321
+ * const score = withShadowScore({ overall: 20 }, { deterministicScore: { overall: 25 } });
322
+ */
223
323
  export function withShadowScore(parsed, { deterministicScore, config = {}, logger } = {}) {
224
324
  const llmOverall = toFiniteScore(parsed?.overall);
225
325
  const llmScore = {
@@ -246,6 +346,19 @@ export function withShadowScore(parsed, { deterministicScore, config = {}, logge
246
346
  };
247
347
  }
248
348
 
349
+ /**
350
+ * Reconcile LLM and deterministic overall scores according to config thresholds.
351
+ *
352
+ * @param {object} [options] Reconciliation inputs.
353
+ * @param {number|null} [options.llmOverall] LLM overall score.
354
+ * @param {object|null} [options.deterministicScore] Deterministic score payload.
355
+ * @param {object} [options.config={}] Effective config.
356
+ * @param {object} [options.logger] Logger for warnings.
357
+ * @returns {{overall: number|null, scorePreference: (object|null)}} Reconciled score and preference source.
358
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
359
+ * @example
360
+ * const result = reconcileScoreOverall({ llmOverall: 20, deterministicScore: { overall: 60 } });
361
+ */
249
362
  export function reconcileScoreOverall({
250
363
  llmOverall,
251
364
  deterministicScore,
@@ -281,6 +394,27 @@ export function reconcileScoreOverall({
281
394
  return { overall, scorePreference };
282
395
  }
283
396
 
397
+ /**
398
+ * Score meaning preservation between original and rewritten text.
399
+ *
400
+ * @param {object} options MPS options.
401
+ * @param {string} options.original Original text.
402
+ * @param {string} options.rewritten Rewritten text.
403
+ * @param {string} [options.apiKey] Provider API key.
404
+ * @param {string} [options.baseURL] Provider base URL.
405
+ * @param {string} [options.model] Model id.
406
+ * @param {number} [options.deadline] Absolute epoch-millisecond deadline.
407
+ * @param {AbortSignal} [options.signal] External cancellation signal.
408
+ * @param {number} [options.timeout] Per-attempt backend timeout in milliseconds.
409
+ * @param {Function} [options.callLLM] Injectable LLM implementation.
410
+ * @param {object} [options.logger] patina logger.
411
+ * @param {Function} [options.now] Clock returning epoch milliseconds.
412
+ * @param {Function} [options.sleep] Sleep helper for tests.
413
+ * @returns {Promise<Object>} MPS result.
414
+ * @throws {Error} When the operation is aborted.
415
+ * @example
416
+ * const mps = await scoreMPS({ original: 'A', rewritten: 'A', callLLM: async () => '{"mps":100,"anchors":[]}' });
417
+ */
284
418
  export async function scoreMPS({
285
419
  original,
286
420
  rewritten,
@@ -289,6 +423,7 @@ export async function scoreMPS({
289
423
  model,
290
424
  deadline,
291
425
  signal,
426
+ timeout,
292
427
  callLLM = defaultCallLLM,
293
428
  logger = createLogger(),
294
429
  now,
@@ -333,6 +468,7 @@ ${rewritten}
333
468
  model,
334
469
  deadline,
335
470
  signal,
471
+ timeout,
336
472
  callLLM,
337
473
  logger,
338
474
  now,
@@ -348,6 +484,15 @@ ${rewritten}
348
484
  }
349
485
  }
350
486
 
487
+ /**
488
+ * Convert a numeric AI-likeness score to a human-readable band.
489
+ *
490
+ * @param {number} score AI-likeness score from 0 to 100.
491
+ * @returns {string} Interpretation band.
492
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
493
+ * @example
494
+ * const label = interpretScore(28); // mostly human
495
+ */
351
496
  export function interpretScore(score) {
352
497
  if (score <= 15) return 'human';
353
498
  if (score <= 30) return 'mostly human';
@@ -357,6 +502,16 @@ export function interpretScore(score) {
357
502
  }
358
503
 
359
504
  // Length ratio is deterministic — bucket per core/scoring.md §10.4.
505
+ /**
506
+ * Score rewritten length ratio on the 0-3 fidelity scale.
507
+ *
508
+ * @param {string} original Original text.
509
+ * @param {string} rewritten Rewritten text.
510
+ * @returns {number} Length-ratio points from 0 to 3.
511
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
512
+ * @example
513
+ * const points = lengthRatioPoints('abcd', 'abcde');
514
+ */
360
515
  export function lengthRatioPoints(original, rewritten) {
361
516
  if (!original || original.length === 0) return 3;
362
517
  const ratio = (rewritten.length / original.length) * 100;
@@ -366,6 +521,27 @@ export function lengthRatioPoints(original, rewritten) {
366
521
  return 0;
367
522
  }
368
523
 
524
+ /**
525
+ * Score fidelity between original and rewritten text using length plus LLM criteria.
526
+ *
527
+ * @param {object} options Fidelity options.
528
+ * @param {string} options.original Original text.
529
+ * @param {string} options.rewritten Rewritten text.
530
+ * @param {string} [options.apiKey] Provider API key.
531
+ * @param {string} [options.baseURL] Provider base URL.
532
+ * @param {string} [options.model] Model id.
533
+ * @param {number} [options.deadline] Absolute epoch-millisecond deadline.
534
+ * @param {AbortSignal} [options.signal] External cancellation signal.
535
+ * @param {number} [options.timeout] Per-attempt backend timeout in milliseconds.
536
+ * @param {Function} [options.callLLM] Injectable LLM implementation.
537
+ * @param {object} [options.logger] patina logger.
538
+ * @param {Function} [options.now] Clock returning epoch milliseconds.
539
+ * @param {Function} [options.sleep] Sleep helper for tests.
540
+ * @returns {Promise<Object>} Fidelity result.
541
+ * @throws {Error} When the operation is aborted.
542
+ * @example
543
+ * const fidelity = await scoreFidelity({ original: 'A', rewritten: 'A', callLLM: async () => '{"criteria":{"meaning":3,"tone":3,"no_unintended_additions":3}}' });
544
+ */
369
545
  export async function scoreFidelity({
370
546
  original,
371
547
  rewritten,
@@ -374,6 +550,7 @@ export async function scoreFidelity({
374
550
  model,
375
551
  deadline,
376
552
  signal,
553
+ timeout,
377
554
  callLLM = defaultCallLLM,
378
555
  logger = createLogger(),
379
556
  now,
@@ -421,6 +598,7 @@ ${rewritten}
421
598
  deadline,
422
599
  signal,
423
600
  callLLM,
601
+ timeout,
424
602
  logger,
425
603
  now,
426
604
  sleep,
@@ -453,6 +631,15 @@ ${rewritten}
453
631
  };
454
632
  }
455
633
 
634
+ /**
635
+ * Clamp and round a value into the inclusive 0-3 scoring range.
636
+ *
637
+ * @param {number|string} v Value to clamp.
638
+ * @returns {number} Integer from 0 to 3.
639
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
640
+ * @example
641
+ * const value = clamp03(4.2); // 3
642
+ */
456
643
  export function clamp03(v) {
457
644
  const n = Number(v);
458
645
  if (!Number.isFinite(n)) return 0;
@@ -467,6 +654,20 @@ function rethrowIfAborted(err, signal) {
467
654
 
468
655
  // Combined score per core/scoring.md §13: AI-likeness × ai_weight + (100 - fidelity) × fidelity_weight.
469
656
  // Lower is better. Falls back to default weights if profile not configured.
657
+ /**
658
+ * Combine AI-likeness, inverted fidelity, and optional deterministic score.
659
+ *
660
+ * @param {object} options Combined score inputs.
661
+ * @param {number} options.aiLikeness AI-likeness score, lower is better.
662
+ * @param {number} options.fidelity Fidelity score, higher is better.
663
+ * @param {string} [options.profile] Profile name for configured weights.
664
+ * @param {object} [options.config] Effective config.
665
+ * @param {number|object|null} [options.deterministicScore] Optional deterministic score.
666
+ * @returns {number} Combined score, lower is better.
667
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
668
+ * @example
669
+ * const score = combinedScore({ aiLikeness: 20, fidelity: 90, profile: 'default', config: {} });
670
+ */
470
671
  export function combinedScore({ aiLikeness, fidelity, profile, config, deterministicScore }) {
471
672
  const profileWeights = config?.ouroboros?.['combined-weights']?.[profile];
472
673
  const ai = profileWeights?.['ai-likeness'] ?? 0.6;
@@ -484,6 +685,12 @@ export function combinedScore({ aiLikeness, fidelity, profile, config, determini
484
685
  return roundScore(aiLikeness * ai + fidelityInverted * fid);
485
686
  }
486
687
 
688
+ function isLexiconEnabledForLanguage(config = {}, lang) {
689
+ if (config.lexicon?.enabled === false) return false;
690
+ const enabledLanguages = config.lexicon?.languages;
691
+ return !Array.isArray(enabledLanguages) || enabledLanguages.includes(lang);
692
+ }
693
+
487
694
  function deterministicScoringOptions(config = {}) {
488
695
  const cfg = config.scoring?.deterministic || {};
489
696
  const enabled = cfg.enabled !== false;
@@ -517,6 +724,7 @@ function emptyDeterministicBands() {
517
724
  burstiness: { low: 0, mid: 0, high: 0, null: 0 },
518
725
  mattr: { low: 0, mid: 0, high: 0, null: 0 },
519
726
  lexicon: { hot: 0, threshold: null },
727
+ koDiagnostics: { hot: 0, thresholds: null },
520
728
  };
521
729
  }
522
730
 
package/src/security.js CHANGED
@@ -8,6 +8,15 @@ import { inputError } from './errors.js';
8
8
 
9
9
  const PROFILE_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
10
10
 
11
+ /**
12
+ * Validate a profile name before resolving profiles/{name}.md.
13
+ *
14
+ * @param {string} name Profile name supplied by CLI or config.
15
+ * @returns {void}
16
+ * @throws {PatinaCliError} When the name is empty, non-string, or contains unsafe characters.
17
+ * @example
18
+ * validateProfileName('default');
19
+ */
11
20
  export function validateProfileName(name) {
12
21
  if (typeof name !== 'string' || !PROFILE_NAME_RE.test(name)) {
13
22
  throw inputError(
@@ -18,6 +27,15 @@ export function validateProfileName(name) {
18
27
  }
19
28
  }
20
29
 
30
+ /**
31
+ * Check whether a hostname is localhost or loopback.
32
+ *
33
+ * @param {string} hostname Hostname from a URL.
34
+ * @returns {boolean} True for localhost, 127/8, or ::1.
35
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
36
+ * @example
37
+ * const local = isLoopbackHost('127.0.0.1');
38
+ */
21
39
  export function isLoopbackHost(hostname) {
22
40
  if (!hostname) return false;
23
41
  if (hostname === 'localhost') return true;
@@ -31,6 +49,15 @@ export function isLoopbackHost(hostname) {
31
49
  // so DNS rebinding is NOT covered by this check. The goal is to catch the
32
50
  // common case: --base-url pointed at 169.254.169.254 (cloud metadata) or
33
51
  // internal RFC 1918 hosts that should not receive Bearer tokens.
52
+ /**
53
+ * Detect literal private, reserved, link-local, metadata, or multicast IP hosts.
54
+ *
55
+ * @param {string} hostname Hostname or bracketed IPv6 literal.
56
+ * @returns {boolean} True when the literal IP is private or special-use.
57
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
58
+ * @example
59
+ * const blocked = isPrivateOrSpecialIP('169.254.169.254');
60
+ */
34
61
  export function isPrivateOrSpecialIP(hostname) {
35
62
  if (!hostname) return false;
36
63
  const h = hostname.startsWith('[') && hostname.endsWith(']')
@@ -63,6 +90,18 @@ export function isPrivateOrSpecialIP(hostname) {
63
90
  return false;
64
91
  }
65
92
 
93
+ /**
94
+ * Validate a provider base URL before sending prompts and bearer tokens.
95
+ *
96
+ * @param {string} baseURL URL to validate.
97
+ * @param {object} [options] Validation opt-ins.
98
+ * @param {boolean} [options.allowInsecure=false] Allow non-loopback HTTP.
99
+ * @param {boolean} [options.allowPrivate=false] Allow private/reserved literal IPs.
100
+ * @returns {void}
101
+ * @throws {PatinaCliError} When the URL is invalid, unsupported, insecure, or private without opt-in.
102
+ * @example
103
+ * validateBaseURL('https://api.openai.com/v1');
104
+ */
66
105
  export function validateBaseURL(baseURL, { allowInsecure = false, allowPrivate = false } = {}) {
67
106
  let url;
68
107
  try {
@@ -108,24 +147,60 @@ export function validateBaseURL(baseURL, { allowInsecure = false, allowPrivate =
108
147
  }
109
148
  }
110
149
 
150
+ /**
151
+ * Read CLI/env opt-in for non-loopback HTTP base URLs.
152
+ *
153
+ * @param {object} [parsed] Parsed CLI options.
154
+ * @returns {boolean} True when insecure base URLs are explicitly allowed.
155
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
156
+ * @example
157
+ * const allowed = shouldAllowInsecureBaseURL({ allowInsecureBaseURL: true });
158
+ */
111
159
  export function shouldAllowInsecureBaseURL(parsed) {
112
160
  if (parsed && parsed.allowInsecureBaseURL) return true;
113
161
  const env = process.env.PATINA_ALLOW_INSECURE_BASE_URL;
114
162
  return env === '1' || env === 'true' || env === 'yes';
115
163
  }
116
164
 
165
+ /**
166
+ * Persist CLI insecure-base-url opt-in into process.env for downstream calls.
167
+ *
168
+ * @param {object} [parsed] Parsed CLI options.
169
+ * @returns {void}
170
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
171
+ * @example
172
+ * applyInsecureBaseURLOptIn({ allowInsecureBaseURL: true });
173
+ */
117
174
  export function applyInsecureBaseURLOptIn(parsed) {
118
175
  if (parsed && parsed.allowInsecureBaseURL) {
119
176
  process.env.PATINA_ALLOW_INSECURE_BASE_URL = '1';
120
177
  }
121
178
  }
122
179
 
180
+ /**
181
+ * Read CLI/env opt-in for private or reserved literal IP base URLs.
182
+ *
183
+ * @param {object} [parsed] Parsed CLI options.
184
+ * @returns {boolean} True when private base URLs are explicitly allowed.
185
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
186
+ * @example
187
+ * const allowed = shouldAllowPrivateBaseURL({ allowPrivateBaseURL: true });
188
+ */
123
189
  export function shouldAllowPrivateBaseURL(parsed) {
124
190
  if (parsed && parsed.allowPrivateBaseURL) return true;
125
191
  const env = process.env.PATINA_ALLOW_PRIVATE_BASE_URL;
126
192
  return env === '1' || env === 'true' || env === 'yes';
127
193
  }
128
194
 
195
+ /**
196
+ * Persist CLI private-base-url opt-in into process.env for downstream calls.
197
+ *
198
+ * @param {object} [parsed] Parsed CLI options.
199
+ * @returns {void}
200
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
201
+ * @example
202
+ * applyPrivateBaseURLOptIn({ allowPrivateBaseURL: true });
203
+ */
129
204
  export function applyPrivateBaseURLOptIn(parsed) {
130
205
  if (parsed && parsed.allowPrivateBaseURL) {
131
206
  process.env.PATINA_ALLOW_PRIVATE_BASE_URL = '1';
@@ -0,0 +1,26 @@
1
+ ---
2
+ fixture_id: en-public-docs-01
3
+ language: en
4
+ profile: default
5
+ register: public-docs
6
+ source_type: synthetic-ai
7
+ model_family: fixture
8
+ prompt_id: live-quality-v2
9
+ redistribution: repo-ok
10
+ anchors:
11
+ - coffee
12
+ - Paris
13
+ - Tokyo
14
+ - climate change
15
+ - supply chains
16
+ expected_focus:
17
+ - reduce promotional abstractions
18
+ - preserve concrete locations and constraints
19
+ ---
20
+ Coffee has emerged as a pivotal cultural phenomenon that has fundamentally transformed social interactions across the globe. This beloved beverage serves as a catalyst for community building, fosters meaningful connections, and facilitates cross-cultural dialogue.
21
+
22
+ In Paris, some cafes still work as neighborhood meeting places. In Tokyo, compact coffee bars often serve commuters who stay for only a few minutes.
23
+
24
+ Roasters are also dealing with climate change. Heat, irregular rain, and disease pressure have made harvest volume less predictable in several growing regions.
25
+
26
+ Supply chains are part of the same problem. A delayed shipment can change a small cafe's menu for a week, even when demand stays steady.
@@ -0,0 +1,26 @@
1
+ ---
2
+ fixture_id: ko-public-docs-01
3
+ language: ko
4
+ profile: default
5
+ register: public-docs
6
+ source_type: synthetic-ai
7
+ model_family: fixture
8
+ prompt_id: live-quality-v2
9
+ redistribution: repo-ok
10
+ anchors:
11
+ - 커피
12
+ - 서울
13
+ - 부산
14
+ - 기후 변화
15
+ - 공급망
16
+ expected_focus:
17
+ - 번역투 완화
18
+ - 장소와 제약 보존
19
+ ---
20
+ 커피는 현대 사회적 상호작용을 근본적으로 변화시킨 핵심적인 문화 현상으로 자리매김하고 있습니다. 이 음료는 공동체 형성을 촉진하고 의미 있는 연결을 가능하게 하며, 다양한 문화권 사이의 대화를 활성화하는 중요한 매개체로 기능합니다.
21
+
22
+ 서울의 카페 거리는 출근 전 짧게 들르는 사람과 오래 앉아 일하는 사람이 같이 쓰는 공간이다. 부산의 로스터리들은 관광객보다 동네 단골을 먼저 상대하는 곳도 많다.
23
+
24
+ 기후 변화는 원두 수급을 흔들고 있다. 산지의 비와 더위가 달라지면 같은 농장의 생두도 해마다 품질 차이가 커진다.
25
+
26
+ 공급망 문제도 작지 않다. 선적이 늦어지면 작은 카페는 일주일 메뉴를 바꿔야 하고, 손님 수요와는 별개로 원가가 흔들린다.