Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "patina",
"source": "./",
"version": "7.0.0",
"version": "7.1.0",
"description": "Strip the AI packaging, keep the meaning. Detect and rewrite AI writing patterns across KO/EN/ZH/JA with MPS/fidelity checks. Runs the root SKILL.md as the /patina skill.",
"homepage": "https://github.com/devswha/patina",
"repository": "https://github.com/devswha/patina",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://www.schemastore.org/claude-code-plugin-manifest.json",
"name": "patina",
"version": "7.0.0",
"version": "7.1.0",
"description": "Detect and rewrite AI writing patterns in Korean, English, Chinese, and Japanese so text reads as if a human wrote it. Meaning-preservation (MPS) verified, audit-friendly. The root SKILL.md is loaded as the /patina skill.",
"author": {
"name": "devswha",
Expand Down
2 changes: 1 addition & 1 deletion .patina.default.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: "7.0.0"
version: "7.1.0"
language: ko # Korean (default) -- auto-loads all ko-*.md patterns
# language: en -- English -- auto-loads all en-*.md patterns
# language: zh -- Chinese -- auto-loads all zh-*.md patterns
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ Semver rationale: patch | minor | major — explain whether this changes pattern

## Unreleased

## 7.1.0 — 2026-08-18

**Adds auditable hosted rewrites, privacy-safe conversion evidence, and measured controls for Korean structure and prompt cost.**

Semver rationale: minor — adds hosted audit/funnel/webhook surfaces and opt-in research/runtime controls without changing the default rewrite prompt or CLI contract.

### Added

- Downloadable `patina-rewrite-receipt-v2` Audit JSON binds the source, latest input, exact prompt, accepted output, verification results, and categorical prompt-budget profile without retaining customer text or provider credentials.
- An application-owned aggregate funnel measures input, rewrite, result-action, checkout-start, and Polar-confirmed initial purchase events without storing raw text, identifiers, URLs, attribution, model/provider identities, hashes, webhook IDs, order data, or customer data.
- Polar `order.paid` webhook verification follows Standard Webhooks, pins the exact nested organization/product identity, counts only positive USD initial purchases, and deduplicates signed deliveries before incrementing the daily aggregate.
- `ko-contextual-v1` and the schema-v4 blind counterbalanced rewrite comparator provide advisory evidence for Korean structure changes while leaving the shipping prompt unchanged.
- Request-shaped prompt budgets support `off`, `shadow`, and `active`; the default is `off`, risky or malformed inputs resolve to strict, and short-safe minimal prompts preserve all post-rewrite safety scoring.
- **`gemini-3.7-flash` allowlisted** on the web BYOK surface and eligible for `PATINA_FREE_MODEL` / `PATINA_PRO_MODEL`, after a 22-fixture live-quality head-to-head against the serving pin on an identical apparatus (`docs/operations/serving-engine-gemini-3.7-flash-20260813.md`): 19 pass vs 18, half the `ai_not_improved` warns, ~2x faster, identical pricing. The serving pin stays `gemini-3.6-flash` — 3.7 shows a reproducible MPS-50 meaning-loss tail on `en-social-01` and worst-case MPS is the deciding column. Opt-in only; all pinned defaults unchanged.
- MiniMax provider presets for the CLI: `--provider minimax` (global, `api.minimax.io`) and `--provider minimax-cn` (China, `api.minimaxi.com`), both defaulting to `MiniMax-M3` on the shared `MINIMAX_API_KEY`. Reimplements community PR #667 by @octo-patch on the current codebase.

## 7.0.0 — 2026-08-04
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
<a href="#quick-start"><img alt="Skill: Claude Code | Codex | Cursor | OpenCode" src="https://img.shields.io/badge/Skill-Claude%20Code%20%7C%20Codex%20%7C%20Cursor%20%7C%20OpenCode-blueviolet"></a>
<a href="https://github.com/devswha/patina"><img alt="Languages: KO | EN | ZH | JA" src="https://img.shields.io/badge/Languages-KO%20%7C%20EN%20%7C%20ZH%20%7C%20JA-green"></a>
<a href="CHANGELOG.md"><img alt="Version 7.0.0" src="https://img.shields.io/badge/version-7.0.0-blue"></a>
<a href="CHANGELOG.md"><img alt="Version 7.1.0" src="https://img.shields.io/badge/version-7.1.0-blue"></a>
</p>

<p align="center">
Expand Down Expand Up @@ -100,7 +100,7 @@ patina --batch docs/*.md --outdir cleaned/
Project config lives in `.patina.yaml`:

```yaml
version: "7.0.0"
version: "7.1.0"
language: ko # ko | en | zh | ja
document-type: default # genre/purpose + pattern policy
persona: # optional reusable voice; omit to preserve source
Expand Down
4 changes: 2 additions & 2 deletions README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Skill](https://img.shields.io/badge/Skill-Claude%20Code%20%7C%20Codex%20%7C%20Cursor%20%7C%20OpenCode-blueviolet)](#クイックスタート)
[![Multi-language](https://img.shields.io/badge/Languages-KO%20%7C%20EN%20%7C%20ZH%20%7C%20JA-green)](https://github.com/devswha/patina)
[![Version](https://img.shields.io/badge/version-7.0.0-blue)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-7.1.0-blue)](CHANGELOG.md)

> **AIっぽさだけを落として、意味はそのまま。**

Expand Down Expand Up @@ -98,7 +98,7 @@ patina --batch docs/*.md --outdir cleaned/

```yaml
# .patina.default.yaml
version: "7.0.0"
version: "7.1.0"
language: ko # ko | en | zh | ja
document-type: default # ジャンル/用途 + パターン方針
persona: # 任意。省略時は原文ボイスを保持
Expand Down
4 changes: 2 additions & 2 deletions README_KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Skill](https://img.shields.io/badge/Skill-Claude%20Code%20%7C%20Codex%20%7C%20Cursor%20%7C%20OpenCode-blueviolet)](#빠른-시작)
[![Multi-language](https://img.shields.io/badge/Languages-KO%20%7C%20EN%20%7C%20ZH%20%7C%20JA-green)](https://github.com/devswha/patina)
[![Version](https://img.shields.io/badge/version-7.0.0-blue)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-7.1.0-blue)](CHANGELOG.md)

<p align="center">
<a href="https://patina.vibetip.help/"><b>브라우저에서 바로 써보기 — 설치 없음</b></a>
Expand Down Expand Up @@ -98,7 +98,7 @@ patina --batch docs/*.md --outdir cleaned/

```yaml
# .patina.default.yaml
version: "7.0.0"
version: "7.1.0"
language: ko # ko | en | zh | ja
document-type: default # 장르·용도 + 패턴 정책
persona: # 선택 사항; 생략하면 원문 보이스 보존
Expand Down
4 changes: 2 additions & 2 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Skill](https://img.shields.io/badge/Skill-Claude%20Code%20%7C%20Codex%20%7C%20Cursor%20%7C%20OpenCode-blueviolet)](#快速开始)
[![Multi-language](https://img.shields.io/badge/Languages-KO%20%7C%20EN%20%7C%20ZH%20%7C%20JA-green)](https://github.com/devswha/patina)
[![Version](https://img.shields.io/badge/version-7.0.0-blue)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-7.1.0-blue)](CHANGELOG.md)

<p align="center">
<strong>去掉 AI 味,保留原意。</strong>
Expand Down Expand Up @@ -100,7 +100,7 @@ patina --batch docs/*.md --outdir cleaned/

```yaml
# .patina.default.yaml
version: "7.0.0"
version: "7.1.0"
language: ko # ko | en | zh | ja
document-type: default # 体裁/用途 + 模式策略
persona: # 可选;省略时保留原文声音
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: patina
version: "7.0.0"
version: "7.1.0"
description: Detect and rewrite AI writing patterns in Korean, English, Chinese, and Japanese text so it reads as if a human wrote it. Meaning-preservation (MPS) verified.
allowed-tools:
- Read
Expand Down
180 changes: 180 additions & 0 deletions api/funnel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// @ts-check
import { funnelCounterKey, validateFunnelEvent } from '../src/funnel-analytics.js';

export const config = { api: { bodyParser: false } };

const BODY_LIMIT = 4096;
const TTL_SECONDS = 35 * 24 * 60 * 60;
const DEFAULT_EVENTS_PER_DAY = 10_000;
const DAILY_LIMIT_CODE = 'FUNNEL_DAILY_LIMIT';
const INCREMENT_WITH_DAILY_LIMIT = "local total = redis.call('INCRBY', KEYS[1], ARGV[1]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) if total > tonumber(ARGV[3]) then return -1 end local v = redis.call('INCRBY', KEYS[2], ARGV[1]) redis.call('PEXPIRE', KEYS[2], ARGV[2]) return v";

/** @param {unknown} value */
function headerValue(value) {
if (Array.isArray(value)) return value.length === 1 ? value[0] : null;
return typeof value === 'string' ? value : null;
}

/** @param {any} req @param {string} name */
function header(req, name) {
const headers = req?.headers;
if (headers?.get) return headerValue(headers.get(name));
if (!headers || typeof headers !== 'object') return null;
const key = Object.keys(headers).find((item) => item.toLowerCase() === name);
return key ? headerValue(headers[key]) : null;
}

/** @param {any} res @param {number} status */
function respond(res, status) {
res.statusCode = status;
res.setHeader?.('Cache-Control', 'no-store');
res.setHeader?.('Content-Length', '0');
res.end?.();
}

/** @param {any} req */
function isSameOriginRequest(req) {
const origin = header(req, 'origin');
const forwardedProto = header(req, 'x-forwarded-proto');
const forwardedHost = header(req, 'x-forwarded-host');
if (!origin || !forwardedProto || !forwardedHost || header(req, 'sec-fetch-site') !== 'same-origin') return false;
if (!/^(https?|HTTPS?)$/.test(forwardedProto) || /[\s,]/.test(forwardedHost)) return false;
try {
const parsed = new URL(origin);
return origin === parsed.origin && parsed.protocol === `${forwardedProto.toLowerCase()}:` && parsed.host.toLowerCase() === forwardedHost.toLowerCase();
} catch {
return false;
}
}

/** @param {any} req @param {Record<string,string|undefined>} env */
function isAllowedDeploymentHost(req, env) {
if (env.VERCEL !== '1' && env.NODE_ENV !== 'production') return true;
const allowed = new Set();
for (const name of ['VERCEL_URL', 'VERCEL_BRANCH_URL', 'VERCEL_PROJECT_PRODUCTION_URL']) {
const value = env[name];
if (value && !/[/?#@\s,]/.test(value)) allowed.add(value.toLowerCase());
}
if (env.PATINA_PUBLIC_BASE_URL) {
try {
const url = new URL(env.PATINA_PUBLIC_BASE_URL);
if (url.protocol === 'https:' && url.pathname === '/' && !url.search && !url.hash) allowed.add(url.host.toLowerCase());
} catch {
return false;
}
}
const host = header(req, 'x-forwarded-host');
return Boolean(host && allowed.size > 0 && allowed.has(host.toLowerCase()));
}

/** @param {any} req */
async function requestBody(req) {
if (req?.body !== undefined) {
const body = req.body;
if (typeof body === 'string') {
if (Buffer.byteLength(body) > BODY_LIMIT) return { tooLarge: true };
return { text: body };
}
if (body instanceof Uint8Array) {
if (body.byteLength > BODY_LIMIT) return { tooLarge: true };
return { text: Buffer.from(body).toString('utf8') };
}
try {
const text = JSON.stringify(body);
if (typeof text !== 'string') return { invalid: true };
if (Buffer.byteLength(text) > BODY_LIMIT) return { tooLarge: true };
return { text };
} catch {
return { invalid: true };
}
}
if (!req || typeof req[Symbol.asyncIterator] !== 'function') return { invalid: true };
let size = 0;
const chunks = [];
for await (const chunk of req) {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk);
size += bytes.byteLength;
if (size > BODY_LIMIT) return { tooLarge: true };
chunks.push(bytes);
}
return { text: Buffer.concat(chunks).toString('utf8') };
}

/**
* Dedicated aggregate-only Upstash REST adapter. It cannot read values or
* accept arbitrary commands, so this endpoint cannot persist request context.
* @param {Record<string, string|undefined>} env
* @param {typeof fetch} fetchImpl
*/
export function createFunnelAggregateStore(env = process.env, fetchImpl = globalThis.fetch) {
const base = env.PATINA_OBSERVABILITY_REST_API_URL;
const token = env.PATINA_OBSERVABILITY_REST_API_TOKEN;
if (!base || !token || typeof fetchImpl !== 'function') return null;
let url;
try {
url = new URL(base);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.upstash\.io$/i.test(url.hostname)) return null;
const configuredLimit = Number(env.PATINA_FUNNEL_EVENTS_PER_DAY || DEFAULT_EVENTS_PER_DAY);
if (!Number.isSafeInteger(configuredLimit) || configuredLimit < 1 || configuredLimit > 1_000_000) return null;

return {
async increment(key, { ttlSeconds }) {
const ttlMs = Math.ceil(ttlSeconds * 1000);
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new Error('invalid ttl');
const parts = key.split(':');
if (parts.length < 5 || parts[0] !== 'patina' || parts[1] !== 'funnel' || parts[2] !== 'v1') throw new Error('invalid aggregate key');
const budgetKey = `${parts.slice(0, 4).join(':')}:budget`;
const response = await fetchImpl(url.origin, {
method: 'POST',
redirect: 'error',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(['EVAL', INCREMENT_WITH_DAILY_LIMIT, '2', budgetKey, key, '1', String(ttlMs), String(configuredLimit)]),
});
if (!response?.ok) throw new Error('aggregate storage unavailable');
const result = await response.json();
if (result?.result === -1) {
const error = /** @type {Error & {code: string}} */ (new Error('aggregate daily limit reached'));
error.code = DAILY_LIMIT_CODE;
throw error;
}
if (!Number.isSafeInteger(result?.result) || result.result < 1) throw new Error('invalid aggregate counter');
},
};
}

/**
* @param {{env?: Record<string, string|undefined>, aggregateStore?: {increment(key: string, options: {ttlSeconds: number}): Promise<void>|void}|null, now?: () => Date|number|string, fetchImpl?: typeof fetch}} options
*/
export function createFunnelApiHandler({ env = process.env, aggregateStore, now = () => new Date(), fetchImpl = globalThis.fetch } = {}) {
const store = aggregateStore === undefined ? createFunnelAggregateStore(env, fetchImpl) : aggregateStore;
return async (req, res) => {
if (req?.method !== 'POST') return respond(res, 405);
if (!isSameOriginRequest(req)) return respond(res, 403);
if (!isAllowedDeploymentHost(req, env)) return respond(res, 403);
const contentType = header(req, 'content-type');
if (!contentType || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(contentType)) return respond(res, 400);
const body = await requestBody(req);
if (body.tooLarge) return respond(res, 413);
if (body.invalid || typeof body.text !== 'string') return respond(res, 400);
let event;
try {
event = JSON.parse(body.text);
} catch {
return respond(res, 400);
}
if (!validateFunnelEvent(event)) return respond(res, 400);
if (!store) return respond(res, 503);
try {
await store.increment(funnelCounterKey(event, now()), { ttlSeconds: TTL_SECONDS });
} catch (error) {
if (/** @type {any} */ (error)?.code === DAILY_LIMIT_CODE) return respond(res, 429);
return respond(res, 503);
}
return respond(res, 204);
};
}

export default createFunnelApiHandler();
Loading