patina-cli 3.11.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 (180) hide show
  1. package/.patina.default.yaml +211 -0
  2. package/CHANGELOG.md +265 -0
  3. package/LICENSE +21 -0
  4. package/README.md +319 -0
  5. package/README_JA.md +254 -0
  6. package/README_KR.md +253 -0
  7. package/README_ZH.md +254 -0
  8. package/SKILL-MAX.md +455 -0
  9. package/SKILL.md +730 -0
  10. package/assets/brand/patina-icon.svg +9 -0
  11. package/assets/brand/patina-logo.svg +17 -0
  12. package/assets/social/patina-before-after.svg +46 -0
  13. package/assets/social/patina-og.svg +31 -0
  14. package/bin/patina.js +9 -0
  15. package/core/scoring.md +657 -0
  16. package/core/standalone-prompt.md +364 -0
  17. package/core/stylometry.md +754 -0
  18. package/core/voice.md +163 -0
  19. package/docs/AUTHENTICATION.md +105 -0
  20. package/docs/AUTHENTICATION_KR.md +105 -0
  21. package/docs/BRANDING.md +37 -0
  22. package/docs/CLI.md +80 -0
  23. package/docs/COMPARISON.md +38 -0
  24. package/docs/COOKBOOK.md +173 -0
  25. package/docs/DEMO.md +40 -0
  26. package/docs/ETHICS.md +27 -0
  27. package/docs/EXAMPLES.md +130 -0
  28. package/docs/EXAMPLES_KR.md +130 -0
  29. package/docs/EXIT-CODES.md +25 -0
  30. package/docs/FAQ.md +67 -0
  31. package/docs/FAQ_KR.md +65 -0
  32. package/docs/FLAG-PARITY.md +53 -0
  33. package/docs/GLOSSARY.md +123 -0
  34. package/docs/PATTERNS-EN.md +718 -0
  35. package/docs/PATTERNS-JA.md +706 -0
  36. package/docs/PATTERNS-KO.md +707 -0
  37. package/docs/PATTERNS-ZH.md +706 -0
  38. package/docs/PATTERNS.md +22 -0
  39. package/docs/ROADMAP.md +315 -0
  40. package/docs/audits/2026-05-deep-research.md +290 -0
  41. package/docs/benchmarks/detector-comparison.json +442 -0
  42. package/docs/benchmarks/detector-comparison.md +65 -0
  43. package/docs/benchmarks/latest.json +988 -0
  44. package/docs/benchmarks/latest.md +112 -0
  45. package/docs/integrations/docker.md +19 -0
  46. package/docs/integrations/github-action.md +59 -0
  47. package/docs/integrations/pre-commit.md +77 -0
  48. package/docs/integrations/release.md +43 -0
  49. package/docs/internal/HARNESS.md +14 -0
  50. package/docs/internal/README.md +14 -0
  51. package/docs/internal/WARP.md +23 -0
  52. package/docs/research/2025-rebaseline-plan.md +89 -0
  53. package/docs/research/ai-human-metrics.md +380 -0
  54. package/docs/social/gstack-cardnews.html +236 -0
  55. package/docs/social/gstack-cardnews.md +88 -0
  56. package/docs/social/gstack-thread.md +106 -0
  57. package/docs/social/patina-launch-copy.md +227 -0
  58. package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
  59. package/lexicon/ai-en.md +162 -0
  60. package/lexicon/ai-ko.md +159 -0
  61. package/package.json +100 -0
  62. package/patina-max/SKILL.md +523 -0
  63. package/patina-max/composite.py +457 -0
  64. package/patterns/en-communication.md +89 -0
  65. package/patterns/en-content.md +133 -0
  66. package/patterns/en-filler.md +113 -0
  67. package/patterns/en-language.md +163 -0
  68. package/patterns/en-structure.md +173 -0
  69. package/patterns/en-style.md +139 -0
  70. package/patterns/en-viral-hook.md +211 -0
  71. package/patterns/ja-communication.md +101 -0
  72. package/patterns/ja-content.md +153 -0
  73. package/patterns/ja-filler.md +123 -0
  74. package/patterns/ja-language.md +190 -0
  75. package/patterns/ja-structure.md +142 -0
  76. package/patterns/ja-style.md +147 -0
  77. package/patterns/ja-viral-hook.md +216 -0
  78. package/patterns/ko-communication.md +98 -0
  79. package/patterns/ko-content.md +154 -0
  80. package/patterns/ko-filler.md +105 -0
  81. package/patterns/ko-language.md +182 -0
  82. package/patterns/ko-structure.md +147 -0
  83. package/patterns/ko-style.md +146 -0
  84. package/patterns/ko-viral-hook.md +211 -0
  85. package/patterns/zh-communication.md +101 -0
  86. package/patterns/zh-content.md +153 -0
  87. package/patterns/zh-filler.md +118 -0
  88. package/patterns/zh-language.md +173 -0
  89. package/patterns/zh-structure.md +145 -0
  90. package/patterns/zh-style.md +159 -0
  91. package/patterns/zh-viral-hook.md +216 -0
  92. package/profiles/academic.md +53 -0
  93. package/profiles/blog.md +81 -0
  94. package/profiles/casual-conversation.md +105 -0
  95. package/profiles/code-comment.md +104 -0
  96. package/profiles/commit-message.md +99 -0
  97. package/profiles/default.md +62 -0
  98. package/profiles/email.md +52 -0
  99. package/profiles/formal.md +98 -0
  100. package/profiles/instructional.md +80 -0
  101. package/profiles/legal.md +57 -0
  102. package/profiles/marketing.md +56 -0
  103. package/profiles/medical.md +53 -0
  104. package/profiles/narrative.md +79 -0
  105. package/profiles/release-notes.md +98 -0
  106. package/profiles/social.md +56 -0
  107. package/profiles/technical.md +53 -0
  108. package/scripts/benchmark-report.mjs +252 -0
  109. package/scripts/check-release-metadata.mjs +48 -0
  110. package/scripts/detector-comparison.mjs +267 -0
  111. package/scripts/lint.mjs +40 -0
  112. package/scripts/precommit-score.mjs +31 -0
  113. package/scripts/prose-score.mjs +186 -0
  114. package/scripts/update-benchmark-ranges.mjs +108 -0
  115. package/src/api.js +330 -0
  116. package/src/auth.js +105 -0
  117. package/src/backends/claude-cli.js +112 -0
  118. package/src/backends/codex-cli.js +121 -0
  119. package/src/backends/contract.js +21 -0
  120. package/src/backends/gemini-cli.js +135 -0
  121. package/src/backends/index.js +159 -0
  122. package/src/cache.js +106 -0
  123. package/src/cli.js +1280 -0
  124. package/src/commands/doctor.js +229 -0
  125. package/src/commands/init.js +208 -0
  126. package/src/config.js +126 -0
  127. package/src/errors.js +53 -0
  128. package/src/features/index.js +96 -0
  129. package/src/features/lexicon.js +90 -0
  130. package/src/features/segment.js +49 -0
  131. package/src/features/stylometry.js +50 -0
  132. package/src/loader.js +103 -0
  133. package/src/logger.js +70 -0
  134. package/src/manifest.js +162 -0
  135. package/src/max-mode.js +207 -0
  136. package/src/ouroboros.js +233 -0
  137. package/src/output.js +480 -0
  138. package/src/prompt-builder.js +409 -0
  139. package/src/providers.js +100 -0
  140. package/src/scoring.js +531 -0
  141. package/src/security.js +133 -0
  142. package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
  143. package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
  144. package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
  145. package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
  146. package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
  147. package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
  148. package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
  149. package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
  150. package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
  151. package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
  152. package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
  153. package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
  154. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
  155. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
  156. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
  157. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
  158. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
  159. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
  160. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
  161. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
  162. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
  163. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
  164. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
  165. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
  166. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
  167. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
  168. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
  169. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
  170. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
  171. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
  172. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
  175. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
  176. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
  177. package/tests/quality/README.md +121 -0
  178. package/tests/quality/benchmark.mjs +306 -0
  179. package/tests/quality/detectors.manual.example.json +31 -0
  180. package/tests/quality/dogfood.mjs +44 -0
@@ -0,0 +1,409 @@
1
+ export function buildPrompt({
2
+ config,
3
+ patterns,
4
+ profile,
5
+ voice,
6
+ voiceSample,
7
+ scoring,
8
+ text,
9
+ mode = 'rewrite',
10
+ tone = null,
11
+ promptMode = 'strict',
12
+ variants = 1,
13
+ }) {
14
+ // v3.11+ prompt-mode dispatch (case-04 hypothesis test). minimal prompt
15
+ // strips pattern definitions/examples and uses a casual instruction; only
16
+ // applies to rewrite mode where voice prior matters most. Profile body is
17
+ // still passed through (Round 2 found Gemini ignored casual-conversation
18
+ // when the profile was dropped).
19
+ if (promptMode === 'minimal' && mode === 'rewrite') {
20
+ return buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone, variants });
21
+ }
22
+
23
+ const lang = config.language || 'ko';
24
+ const profileName = config.profile || 'default';
25
+
26
+ // score_only packs (e.g., viral-hook) are detection-only: included in score
27
+ // and audit modes but excluded from rewrite/diff/ouroboros so we don't force
28
+ // edits to viral-hook patterns that may be intentional rhetoric.
29
+ const includeScoreOnly = mode === 'score' || mode === 'audit';
30
+ const activePatterns = includeScoreOnly
31
+ ? patterns
32
+ : patterns.filter((p) => !p.isScoreOnly);
33
+
34
+ const structurePacks = activePatterns.filter((p) => p.isStructure);
35
+ const lexicalPacks = activePatterns.filter((p) => !p.isStructure);
36
+
37
+ let prompt = `You are an editor who detects and removes AI writing patterns from text, rewriting it into natural, human-written prose.\n\n`;
38
+
39
+ // Tone context (v3.10). Surface resolved tone metadata at the top so the LLM
40
+ // applies Phase 4.5b/5b/6 logic per SKILL.md. Body text in rewrite mode must
41
+ // not leak tone metadata (A7) — only the YAML footer at the end carries it.
42
+ if (tone && tone.tone_source) {
43
+ prompt += `## Tone Resolution (v3.10)\n\n`;
44
+ prompt += `- resolved_tone: ${tone.tone === null ? 'null' : tone.tone}\n`;
45
+ prompt += `- tone_source: ${tone.tone_source}\n`;
46
+ prompt += `- tone_evidence: ${JSON.stringify(tone.tone_evidence ?? [])}\n`;
47
+ prompt += `- tone_confidence: ${tone.tone_confidence ?? 'null'}\n`;
48
+ if (tone.tone_source === 'auto') {
49
+ prompt += `\nRun Phase 4.5b heuristic detection per SKILL.md to resolve a single tone, evidence, and confidence. Apply Phase 5b tone-derived overrides (replace, not stack) and emit Phase 6 YAML footer.\n`;
50
+ } else if (tone.tone_source === 'user') {
51
+ prompt += `\nApply Phase 5b tone-derived overrides for "${tone.tone}" (replace, not stack with profile overrides). Emit Phase 6 YAML footer with these exact values.\n`;
52
+ } else if (tone.tone_source === 'unsupported_language_fallback') {
53
+ prompt += `\nzh/ja with explicit tone is unsupported in v1; proceed in profile-only mode. Emit Phase 6 YAML footer with tone: null and the fallback warning preserved in tone_evidence.\n`;
54
+ } else if (tone.tone_source === 'profile_only') {
55
+ prompt += `\nNo tone specified — profile-only mode (regression-safe path). Phase 4.5b is skipped. Emit Phase 6 YAML footer with tone: null and tone_source: profile_only.\n`;
56
+ }
57
+ prompt += `\n`;
58
+ }
59
+
60
+ prompt += `## Configuration\n\n`;
61
+ prompt += `- Language: ${lang}\n`;
62
+ prompt += `- Profile: ${profileName}\n`;
63
+ prompt += `- Output mode: ${mode}\n`;
64
+ if (config.blocklist?.length > 0) {
65
+ prompt += `- Blocklist: ${config.blocklist.join(', ')}\n`;
66
+ }
67
+ if (config.allowlist?.length > 0) {
68
+ prompt += `- Allowlist: ${config.allowlist.join(', ')}\n`;
69
+ }
70
+ prompt += `\n`;
71
+
72
+ prompt += `## Pattern Packs\n\n`;
73
+ for (const pack of activePatterns) {
74
+ prompt += `### Pack: ${pack.frontmatter?.pack || pack.file}\n\n`;
75
+ prompt += `${pack.body}\n\n`;
76
+ }
77
+
78
+ prompt += `## Profile\n\n`;
79
+ if (profile) {
80
+ prompt += `${profile.body}\n\n`;
81
+ }
82
+
83
+ prompt += `## Voice Guidelines\n\n`;
84
+ if (voice) {
85
+ prompt += `${voice.body}\n\n`;
86
+ }
87
+
88
+ if ((mode === 'rewrite' || mode === 'ouroboros') && voiceSample) {
89
+ prompt += formatVoiceSampleSection(voiceSample);
90
+ }
91
+
92
+ if (mode === 'score' || mode === 'ouroboros') {
93
+ prompt += `## Scoring Algorithm\n\n`;
94
+ if (scoring) {
95
+ prompt += `${scoring.body}\n\n`;
96
+ }
97
+ }
98
+
99
+ prompt += `## Instructions\n\n`;
100
+ prompt += `Process the following text according to the output mode "${mode}".\n\n`;
101
+
102
+ if (mode === 'rewrite') {
103
+ prompt += buildRewriteInstructions(structurePacks, lexicalPacks, { variants });
104
+ } else if (mode === 'diff') {
105
+ prompt += buildDiffInstructions();
106
+ } else if (mode === 'audit') {
107
+ prompt += buildAuditInstructions();
108
+ } else if (mode === 'score') {
109
+ prompt += buildScoreInstructions(config, lang, text);
110
+ } else if (mode === 'ouroboros') {
111
+ prompt += buildOuroborosInstructions(config, structurePacks, lexicalPacks);
112
+ }
113
+
114
+ prompt += `\n## Input Text\n\n${text}\n\n`;
115
+ prompt += `## Output\n\n`;
116
+
117
+ return prompt;
118
+ }
119
+
120
+ function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit = true, variants = 1 } = {}) {
121
+ const phaseCount = includeSelfAudit ? 3 : 2;
122
+ let inst = `Follow the ${phaseCount}-Phase pipeline:\n\n`;
123
+
124
+ if (structurePacks.length > 0) {
125
+ inst += `### Phase 1: Structure Scan\n\n`;
126
+ inst += `Apply the structure patterns to fix document-level issues:\n`;
127
+ for (const pack of structurePacks) {
128
+ inst += `- ${pack.frontmatter?.pack || pack.file}\n`;
129
+ }
130
+ inst += `\n1. Scan paragraph layout, repetition, translationese, passive patterns\n`;
131
+ inst += `2. Correct structural issues — diversify paragraph structure\n`;
132
+ inst += `3. Verify core claims and logical flow survive structural changes\n`;
133
+ inst += `4. Intentionally vary paragraph length and sentence count (burstiness)\n\n`;
134
+ inst += `**Skip if**: text is ≤2 paragraphs OR no structure packs loaded.\n\n`;
135
+ }
136
+
137
+ inst += `### Phase 2: Sentence/Lexical Rewrite\n\n`;
138
+ inst += `Apply all remaining pattern packs (content, language, style, communication, filler):\n`;
139
+ for (const pack of lexicalPacks) {
140
+ inst += `- ${pack.frontmatter?.pack || pack.file}\n`;
141
+ }
142
+ inst += `\n1. Scan all patterns for AI tells\n`;
143
+ inst += `2. Rewrite AI-sounding expressions into natural alternatives\n`;
144
+ inst += `3. Preserve core meaning, claims, polarity, causation, numbers\n`;
145
+ inst += `4. Match profile tone\n`;
146
+ inst += `5. Inject personality per voice guidelines\n`;
147
+ inst += `6. Respect blocklist/allowlist and pattern overrides\n\n`;
148
+
149
+ if (includeSelfAudit) {
150
+ inst += `### Phase 3: Self-Audit\n\n`;
151
+ inst += `1. Scan for remaining AI tells\n`;
152
+ inst += `2. Verify no polarity inversions (negation → positive or vice versa)\n`;
153
+ inst += `3. Ensure Phase 1 corrections were not reverted in Phase 2\n`;
154
+ inst += `4. Final check: meaning preserved?\n\n`;
155
+
156
+ inst += buildOutputFormatBlock({ variants });
157
+ } else {
158
+ // Self-audit suppressed: external evaluators (scoreText, scoreMPS,
159
+ // scoreFidelity) handle AI-tell detection, polarity, and meaning checks
160
+ // downstream. Output only the rewritten text so iterations stay clean.
161
+ inst += `Output ONLY the final humanized text. Do not include analysis, ` +
162
+ `pattern lists, or commentary — downstream evaluators handle that.\n`;
163
+ }
164
+
165
+ return inst;
166
+ }
167
+
168
+ // v3.11: emit the strict-mode output-format block. Single-variant uses
169
+ // [BODY]/[/BODY]; --variants > 1 uses [VARIANT n]/[/VARIANT] blocks.
170
+ function buildOutputFormatBlock({ variants = 1 } = {}) {
171
+ const isVariants = variants > 1;
172
+ const tag = isVariants ? '[VARIANT n]/[/VARIANT]' : '[BODY]/[/BODY]';
173
+ const itemDesc = isVariants
174
+ ? `Produce ${variants} stylistic VARIANTS of the rewrite, each wrapped in ` +
175
+ `\`[VARIANT n]\`/\`[/VARIANT]\` tags where n is 1..${variants}. Each ` +
176
+ `variant must preserve all facts, numbers, and causation, but differ in ` +
177
+ `voice (e.g., V1 casual conversational, V2 direct/punchy, V3 measured/` +
178
+ `professional). No headings, no preamble inside the tags.`
179
+ : `The rewritten text wrapped in \`[BODY]\`/\`[/BODY]\` tags. The body ` +
180
+ `block must contain ONLY the user-facing rewrite — no headings, no ` +
181
+ `Phase labels, no preamble like "잔여 AI 티" or "최종 결과물".`;
182
+ const auditDesc = isVariants
183
+ ? `(brief: what differs across variants, residual AI signals, applied patterns)`
184
+ : `(brief: what still looks AI-written, which patterns were applied). ` +
185
+ `This block is for downstream review — patina strips it before showing the user`;
186
+
187
+ let exampleBody = '';
188
+ if (isVariants) {
189
+ for (let i = 1; i <= variants; i++) {
190
+ exampleBody += `[VARIANT ${i}]\n<rewritten text — voice ${i}>\n[/VARIANT]\n\n`;
191
+ }
192
+ } else {
193
+ exampleBody = `[BODY]\n<rewritten text>\n[/BODY]\n\n`;
194
+ }
195
+
196
+ return (
197
+ `### Output format (STRICT — v3.11)\n\n` +
198
+ `Produce output in this exact order, with no other text outside the tagged blocks:\n\n` +
199
+ `1. ${itemDesc}\n` +
200
+ `2. Self-audit notes wrapped in \`[SELF_AUDIT]\`/\`[/SELF_AUDIT]\` tags ${auditDesc}.\n` +
201
+ `3. The Phase 6 YAML footer if tone resolution requires it.\n\n` +
202
+ `Example shape (uses ${tag}):\n\n` +
203
+ '```\n' +
204
+ exampleBody +
205
+ `[SELF_AUDIT]\n- ${isVariants ? 'voice axis' : 'residual signals'}: ...\n` +
206
+ `- ${isVariants ? 'residual signals' : 'patterns applied'}: ...\n[/SELF_AUDIT]\n\n` +
207
+ `---\ntone: ...\ntone_source: ...\ntone_evidence: [...]\ntone_confidence: ...\n---\n` +
208
+ '```\n'
209
+ );
210
+ }
211
+
212
+ function buildDiffInstructions() {
213
+ return `Show what changed and why, pattern by pattern. For each change use this exact label format:\n\n` +
214
+ `Pattern: N. Pattern Name\n` +
215
+ `Removed: original text\n` +
216
+ `Added: corrected text\n` +
217
+ `Why: one short reason\n\n` +
218
+ `Use the exact \`N. Pattern Name\` from the loaded packs. Do not invent pattern names.\n`;
219
+ }
220
+
221
+ function buildAuditInstructions() {
222
+ return `Detect AI patterns ONLY — do not rewrite. Output a table.\n\n` +
223
+ `**Strict requirements:**\n` +
224
+ `- Use the EXACT pattern name AND number from the loaded Pattern Packs above. ` +
225
+ `Format: \`N. Pattern Name\` (e.g., \`30. Rhetorical Question Openers\` or \`13. Em Dash Overuse\`). ` +
226
+ `Do not paraphrase, abbreviate, or invent names.\n` +
227
+ `- The Category column must be the exact pack name from the loaded packs ` +
228
+ `(e.g., \`en-structure\`, \`ko-filler\`, \`zh-content\`). Do not use generic ` +
229
+ `category names like "Style", "Filler", or "Content".\n` +
230
+ `- If you suspect an AI tell that doesn't match any loaded pattern exactly, ` +
231
+ `omit it from the table rather than coining a new name.\n\n` +
232
+ `Output format:\n` +
233
+ `| Pattern | Category | Severity | Location |\n` +
234
+ `|---------|----------|----------|----------|\n`;
235
+ }
236
+
237
+ function buildScoreInstructions(config, lang, text = '') {
238
+ const weights = config.ouroboros?.['category-weights']?.[lang] || {};
239
+ let inst = `Calculate an AI-likeness score (0-100) using EXACTLY these category weights. Do NOT invent extra categories (no "discord", no "tone", no "general"). Use only the categories listed:\n\n`;
240
+
241
+ for (const [cat, weight] of Object.entries(weights)) {
242
+ inst += `- ${cat}: ${weight}\n`;
243
+ }
244
+
245
+ inst += `\nSeverity scale: Low=1, Medium=2, High=3 points per detection.\n`;
246
+ inst += `Category score = (sum of adjusted severities / (pattern_count × 3)) × 100\n`;
247
+ inst += `Overall = weighted average using the EXACT weights above (sum should equal 1.00).\n\n`;
248
+
249
+ // v3.11 Phase 3.2: short text (~200 chars or ≤3 paragraphs) often shows
250
+ // clear voice/register shifts that the standard formula barely registers
251
+ // because so few pattern instances accumulate. Tell the model to apply a
252
+ // 1.5x severity multiplier to register-sensitive categories (language,
253
+ // style, viral-hook) in this regime, capped at 3 (High) per detection.
254
+ const isShort = isShortText(text);
255
+ if (isShort) {
256
+ inst += `**Short-text boost (input ≤200 chars OR ≤3 paragraphs):** for `;
257
+ inst += `register-sensitive categories (\`language\`, \`style\`, \`viral-hook\`) `;
258
+ inst += `apply a 1.5x severity multiplier per detection (cap at 3). This `;
259
+ inst += `surfaces voice/register shifts (e.g., \`~다\` ↔ \`~습니다\` swap) `;
260
+ inst += `that the long-text formula otherwise undercounts.\n\n`;
261
+ }
262
+
263
+ inst += `Output format (the Weight column must echo the values above verbatim):\n`;
264
+ inst += `| Category | Weight | Detected | Raw Score | Weighted |\n`;
265
+ inst += `|----------|--------|----------|-----------|----------|\n`;
266
+ inst += `| **Overall** | | | | **XX.X (±10)** |\n\n`;
267
+ inst += `Interpretation: 0-15 human | 16-30 mostly human | 31-50 mixed | 51-70 AI-like | 71-100 heavily AI\n`;
268
+
269
+ return inst;
270
+ }
271
+
272
+ // v3.11 Phase 3.2 helper: classify a text as "short" for scoring boost.
273
+ // Threshold: ≤200 non-whitespace chars OR ≤3 non-empty paragraphs.
274
+ export function isShortText(text) {
275
+ if (!text) return true;
276
+ const stripped = text.replace(/\s+/g, '');
277
+ if (stripped.length <= 200) return true;
278
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
279
+ return paragraphs.length <= 3;
280
+ }
281
+
282
+ // v3.11 minimal prompt — case-04 hypothesis test.
283
+ // Strips pattern definitions/examples and uses a casual instruction so the
284
+ // model's natural voice prior isn't overridden by analytical framing. Only
285
+ // invoked for rewrite mode; score/audit/diff/ouroboros stay on the strict
286
+ // path because they need precise pattern references.
287
+ function buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone, variants = 1 }) {
288
+ const lang = config.language || 'ko';
289
+ const activePatterns = patterns.filter((p) => !p.isScoreOnly);
290
+
291
+ const watchWords = [];
292
+ for (const pack of activePatterns) {
293
+ const packName = pack.frontmatter?.pack || pack.file;
294
+ const words = extractWatchWords(pack.body);
295
+ if (words.length > 0) {
296
+ watchWords.push(`- **${packName}**: ${words.join(', ')}`);
297
+ }
298
+ }
299
+
300
+ const instruction = lang === 'ko'
301
+ ? `이 글이 AI가 쓴 것 같아 보여서 사람이 쓴 것처럼 자연스럽게 다듬어줘. 아래 어휘들이 보이면 자연스러운 한국어로 풀어줘. 무리하게 의역하지 말고 의미·숫자·인과관계는 그대로 보존해.`
302
+ : `This text reads like AI. Rewrite it so it sounds like a real person wrote it. If you spot any of the phrases below, swap them out for something natural. Don't over-paraphrase — keep the meaning, numbers, and causation intact.`;
303
+
304
+ let prompt = `${instruction}\n\n`;
305
+
306
+ if (watchWords.length > 0) {
307
+ prompt += lang === 'ko' ? `## AI 신호 어휘 (참고)\n\n` : `## AI signal words (reference)\n\n`;
308
+ prompt += watchWords.join('\n');
309
+ prompt += '\n\n';
310
+ }
311
+
312
+ // v3.11 Round 2 fix: profile body must reach the model in minimal mode too,
313
+ // otherwise voice profiles like casual-conversation get ignored. Keep it
314
+ // compact — just the profile body, no full pattern-overrides table.
315
+ if (profile && profile.body) {
316
+ prompt += lang === 'ko' ? `## 톤·프로필 가이드\n\n` : `## Tone & profile guide\n\n`;
317
+ prompt += `${profile.body}\n\n`;
318
+ }
319
+
320
+ if (tone && tone.tone_source) {
321
+ prompt += lang === 'ko' ? `## 톤 메타\n` : `## Tone metadata\n`;
322
+ prompt += `- tone: ${tone.tone === null ? 'null' : tone.tone}\n`;
323
+ prompt += `- source: ${tone.tone_source}\n\n`;
324
+ }
325
+
326
+ if (voiceSample) {
327
+ prompt += formatVoiceSampleSection(voiceSample);
328
+ }
329
+
330
+ prompt += lang === 'ko' ? `## 출력 형식\n\n` : `## Output format\n\n`;
331
+ if (variants > 1) {
332
+ prompt += `1. ${variants}개 voice variant를 각각 \`[VARIANT 1]\` ~ \`[VARIANT ${variants}]\` ` +
333
+ `태그 안에. 사실·숫자·인과관계는 동일하되 voice만 다르게 (예: V1 캐주얼 대화체, V2 직설·짧은 문장, V3 정중·차분).\n`;
334
+ prompt += `2. \`[SELF_AUDIT]\` ... \`[/SELF_AUDIT]\` 안에 짧게: variant별 voice 차이, 남은 AI 신호.\n`;
335
+ prompt += `3. 톤 정보가 있으면 마지막에 YAML 푸터.\n\n`;
336
+ } else {
337
+ prompt += `1. 다듬은 본문을 \`[BODY]\` ... \`[/BODY]\` 안에. 본문만, 머리말·메타·"최종 결과물" 같은 라벨 없이.\n`;
338
+ prompt += `2. \`[SELF_AUDIT]\` ... \`[/SELF_AUDIT]\` 안에 짧게: 어떤 부분 손봤는지, 남은 AI 신호 있는지.\n`;
339
+ prompt += `3. 톤 정보가 있으면 마지막에 YAML 푸터: \`---\\ntone: ...\\ntone_source: ...\\ntone_evidence: [...]\\ntone_confidence: ...\\n---\`\n\n`;
340
+ }
341
+
342
+ prompt += lang === 'ko' ? `## 입력\n\n${text}\n\n` : `## Input\n\n${text}\n\n`;
343
+ prompt += lang === 'ko' ? `## 출력\n\n` : `## Output\n\n`;
344
+
345
+ return prompt;
346
+ }
347
+
348
+ function formatVoiceSampleSection(voiceSample) {
349
+ const paragraphs = Array.isArray(voiceSample?.paragraphs)
350
+ ? voiceSample.paragraphs
351
+ : String(voiceSample?.body || '')
352
+ .split(/\n\s*\n/)
353
+ .map((paragraph) => paragraph.trim())
354
+ .filter(Boolean)
355
+ .slice(0, 3);
356
+ if (paragraphs.length === 0) return '';
357
+
358
+ let section = `## Voice Anchor Examples\n\n`;
359
+ section += `These are examples of how this person writes. Use them as a style/register anchor only: match cadence, specificity, point of view, and sentence texture, but do not import facts, names, claims, or events from the samples. If profile or tone settings conflict, keep the requested profile/tone as the outer boundary and use the samples to make that boundary sound like the user.\n\n`;
360
+ paragraphs.forEach((paragraph, index) => {
361
+ section += `### Example ${index + 1}\n\n`;
362
+ section += `${paragraph}\n\n`;
363
+ });
364
+ return section;
365
+ }
366
+
367
+ // Extract the comma-separated values that follow a "주의 어휘:" or "Watch words:"
368
+ // label in a pattern pack body. Used by buildMinimalPrompt to compress packs
369
+ // from full definitions+examples down to just the trigger vocab.
370
+ function extractWatchWords(body) {
371
+ const re = /\*\*(?:주의 어휘|Watch words):\*\*\s*([^\n]+)/g;
372
+ const out = [];
373
+ let m;
374
+ while ((m = re.exec(body)) !== null) {
375
+ out.push(m[1].trim());
376
+ }
377
+ return out;
378
+ }
379
+
380
+ function buildOuroborosInstructions(config, structurePacks, lexicalPacks) {
381
+ const ouroboros = config.ouroboros || {};
382
+ const targetScore = ouroboros['target-score'] ?? 30;
383
+ const maxIterations = ouroboros['max-iterations'] ?? 3;
384
+ const plateauThreshold = ouroboros['plateau-threshold'] ?? 10;
385
+ const fidelityFloor = ouroboros['fidelity-floor'] ?? 70;
386
+ const mpsFloor = ouroboros['mps-floor'] ?? 70;
387
+
388
+ let inst = `Iterative self-improvement loop:\n\n`;
389
+ inst += `1. Measure initial AI-likeness score\n`;
390
+ inst += `2. If score ≤ ${targetScore}, stop immediately\n`;
391
+ inst += `3. Repeat (max ${maxIterations} iterations):\n`;
392
+ inst += ` a. Run Phase 1 → Phase 2 → Phase 3 pipeline\n`;
393
+ inst += ` b. Score the result\n`;
394
+ inst += ` c. delta = previous - current (positive = improvement)\n`;
395
+ inst += ` d. Terminate if:\n`;
396
+ inst += ` - Score ≤ ${targetScore} → target met\n`;
397
+ inst += ` - delta < 0 → regression → rollback to previous\n`;
398
+ inst += ` - 0 ≤ delta ≤ ${plateauThreshold} → plateau\n`;
399
+ inst += ` - iteration ≥ ${maxIterations} → max iterations\n`;
400
+ inst += ` - fidelity < ${fidelityFloor} → fidelity violation → rollback\n`;
401
+ inst += ` - MPS < ${mpsFloor} → MPS violation → rollback\n`;
402
+ inst += `4. Output iteration log and final text\n\n`;
403
+ // Skip Phase 3 self-audit: each iteration runs through external evaluators
404
+ // (scoreText, scoreMPS, scoreFidelity) in src/ouroboros.js, so an in-prompt
405
+ // self-audit duplicates work and inflates token cost.
406
+ inst += buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit: false });
407
+
408
+ return inst;
409
+ }
@@ -0,0 +1,100 @@
1
+ // Provider presets: shortcuts for common OpenAI-compatible endpoints.
2
+ // Each provider maps to a base URL + a recommended default model + the env
3
+ // variable users typically set to authenticate. Selecting a provider is
4
+ // equivalent to manually setting --base-url, --model, and the right key.
5
+ import { inputError } from './errors.js';
6
+
7
+ export const PROVIDERS = {
8
+ openai: {
9
+ name: 'openai',
10
+ baseURL: 'https://api.openai.com/v1',
11
+ apiKeyEnv: 'OPENAI_API_KEY',
12
+ defaultModel: 'gpt-4o',
13
+ freeTier: false,
14
+ note: 'Paid. Default OpenAI Platform API.',
15
+ },
16
+ gemini: {
17
+ name: 'gemini',
18
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
19
+ apiKeyEnv: 'GEMINI_API_KEY',
20
+ defaultModel: 'gemini-1.5-flash',
21
+ freeTier: true,
22
+ note: 'Free tier available. Get a key at https://aistudio.google.com/app/apikey',
23
+ },
24
+ groq: {
25
+ name: 'groq',
26
+ baseURL: 'https://api.groq.com/openai/v1',
27
+ apiKeyEnv: 'GROQ_API_KEY',
28
+ defaultModel: 'llama-3.3-70b-versatile',
29
+ freeTier: true,
30
+ note: 'Free tier with rate limits. Get a key at https://console.groq.com/keys',
31
+ },
32
+ together: {
33
+ name: 'together',
34
+ baseURL: 'https://api.together.xyz/v1',
35
+ apiKeyEnv: 'TOGETHER_API_KEY',
36
+ defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo-Free',
37
+ freeTier: true,
38
+ note: 'Free models available (suffix "-Free"). Get a key at https://api.together.xyz/settings/api-keys',
39
+ },
40
+ };
41
+
42
+ export function selectProvider(name) {
43
+ if (!name) return null;
44
+ const provider = PROVIDERS[name];
45
+ if (!provider) {
46
+ throw inputError(
47
+ `Unknown provider: ${name}`,
48
+ `Available providers are: ${Object.keys(PROVIDERS).join(', ')}.`,
49
+ 'Run `patina --list-providers` to inspect provider presets.'
50
+ );
51
+ }
52
+ return provider;
53
+ }
54
+
55
+ export function resolveProviderConfig({ provider, apiKey, baseURL, model }) {
56
+ // Explicit args win. Then provider preset. Then PATINA_* env vars.
57
+ // Returns the resolved { apiKey, baseURL, model } and the source for each
58
+ // (for debugging/auth status).
59
+ const resolved = {
60
+ apiKey: apiKey || null,
61
+ baseURL: baseURL || null,
62
+ model: model || null,
63
+ apiKeySource: apiKey ? 'flag' : null,
64
+ baseURLSource: baseURL ? 'flag' : null,
65
+ modelSource: model ? 'flag' : null,
66
+ };
67
+
68
+ if (provider) {
69
+ if (!resolved.apiKey) {
70
+ const fromEnv = process.env[provider.apiKeyEnv];
71
+ if (fromEnv) {
72
+ resolved.apiKey = fromEnv;
73
+ resolved.apiKeySource = `env:${provider.apiKeyEnv}`;
74
+ }
75
+ }
76
+ if (!resolved.baseURL) {
77
+ resolved.baseURL = provider.baseURL;
78
+ resolved.baseURLSource = `provider:${provider.name}`;
79
+ }
80
+ if (!resolved.model) {
81
+ resolved.model = provider.defaultModel;
82
+ resolved.modelSource = `provider:${provider.name}`;
83
+ }
84
+ }
85
+
86
+ if (!resolved.apiKey && process.env.PATINA_API_KEY) {
87
+ resolved.apiKey = process.env.PATINA_API_KEY;
88
+ resolved.apiKeySource = 'env:PATINA_API_KEY';
89
+ }
90
+ if (!resolved.baseURL) {
91
+ resolved.baseURL = process.env.PATINA_API_BASE || 'https://api.openai.com/v1';
92
+ resolved.baseURLSource = process.env.PATINA_API_BASE ? 'env:PATINA_API_BASE' : 'default';
93
+ }
94
+ if (!resolved.model) {
95
+ resolved.model = process.env.PATINA_MODEL || 'gpt-4o';
96
+ resolved.modelSource = process.env.PATINA_MODEL ? 'env:PATINA_MODEL' : 'default';
97
+ }
98
+
99
+ return resolved;
100
+ }