instruction.md
# Korean Middle Korean Style Converter
## What this skill does
사용자가 한국어 문장을 "한국 중세 국어처럼", "훈민정음/중세국어 느낌으로", "옛 국어 밈체로" 바꾸어 달라고 할 때, 입력문을 **결정론적 Middle Korean-style 문체**로 바꾼다.
이 스킬은 학술적 복원이 아니라 창작용 스타일 변환이다.
- 현대 한국어 조사 일부를 `ᄋᆞᆫ`, `ᄋᆞᆯ`, `애` 같은 중세국어풍 표기로 바꾼다.
- `했다`, `하는`, `말하는` 같은 현대 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 바꾼다.
- 날짜 단위는 `年`, `月`, `日`로 바꾼다.
- 일부 한자어/밈 예시는 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트를 섞는다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않는다.
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
## When to use
- "이 문장을 한국 중세 국어로 바꿔줘"
- "훈민정음 느낌 나는 밈 문장으로 변환해줘"
- "중세국어풍으로 농담을 써줘"
- "아래 글을 옛 국어 말투로 바꿔줘"
## When NOT to use
- 학술 논문, 고문헌 번역, 훈민정음 해례본식 엄밀 표기가 필요한 작업
- 법률·의학·계약 문서처럼 의미 오해가 위험한 문서
- 혐오·괴롭힘·명예훼손 목적의 조롱성 변환
## Prerequisites
- `node` 18+
- 설치된 `korean-middle-korean` skill 디렉터리 안에 `scripts/korean_middle_korean.js` helper 포함
- 별도 API 키 없음
## Workflow
1. 변환할 한국어 텍스트를 받는다.
2. CLI에 번들된 `scripts/korean_middle_korean.js`를 `k-skill exec` 표면으로 실행한다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 보호되어 원문 그대로 복원된다.
3. 기본 JSON 출력에서 `output`을 사용자에게 반환한다.
4. 사용자가 근거를 원하면 `replacements` 배열의 규칙 적용 내역을 요약한다.
5. 학술적 정확성이 필요하다고 보이면 이 스킬은 창작용 스타일 변환임을 먼저 밝힌다.
## CLI examples
```bash
npx -y @nomadamas/k-skill@0 exec korean-middle-korean scripts/korean_middle_korean.js -- --text "민수는 3월 5일 학교에서 공부했다."
npx -y @nomadamas/k-skill@0 exec korean-middle-korean scripts/korean_middle_korean.js -- --text "열애설을 인정했다." --format text
cat input.txt | npx -y @nomadamas/k-skill@0 exec korean-middle-korean scripts/korean_middle_korean.js -- --stdin --format json
npx -y @nomadamas/k-skill@0 exec korean-middle-korean scripts/korean_middle_korean.js -- --file ./input.txt --format text
```
## Response policy
- `output`을 중심으로 답한다.
- "정확한 중세국어 번역"이라고 단정하지 말고, "중세국어풍/창작용 변환"이라고 설명한다.
- 사용자가 원문 의미 보존을 중요하게 말하면, 변환문 뒤에 "의미 보존 확인"을 짧게 덧붙인다.
## Output schema
```json
{
"profile": "middle-korean-style-v1",
"input": "열애설을 인정했다.",
"output": "熱愛說ᄋᆞᆯ 인졍ᄒᆞ엿다〮.",
"replacements": [
{ "kind": "lexicon", "from": "열애설", "to": "熱愛說", "count": 1 }
],
"contract": "Deterministic Korean Middle Korean-style rewrite..."
}
```
## Done when
- `npx -y @nomadamas/k-skill@0 exec korean-middle-korean scripts/korean_middle_korean.js -- --help` 가 동작한다.
- `--text`, `--file`, `--stdin` 입력이 모두 동작한다.
- JSON과 text 출력이 모두 동작한다.
- 이슈 #270의 예시처럼 날짜/Hanja/중세국어풍 조사·어미·성조점이 나타난다.
scripts/korean_middle_korean.js
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const PROFILE = "middle-korean-style-v1";
const CONTRACT =
"Deterministic Korean Middle Korean-style rewrite: public-domain orthographic flavor rules, fixed broad lexicon replacements, archaic particles/endings, Sino-Korean Hanja hints, protected URL/email/Markdown-code spans, and best-effort preservation for names/numbers when no rule matches.";
const LEXICON = [
["야 이", "이"],
["맛국노야", "맛國노〮야"],
["설마", "쇼ᄆᆞ"],
["새벽", "샛ᄇᆡ긔〮"],
["배우", "俳優"],
["구자욱이랑", "구자욱과"],
["거리", "街里"],
["손잡고", "손ᄋᆞᆯ 자ᇙ고"],
["걸어다니는", "거러다니ᄂᆞᆫ"],
["모습", "모ᄉᆡᆸ〮"],
["찍혀", "찍히야"],
["열애설", "熱愛說"],
["터지고", "터ᄂᆞᆺ고"],
["인정했지만", "인졍ᄒᆞ엿거ᄂᆞᆫ"],
["인정했다", "인졍ᄒᆞ엿다〮"],
["인정", "인졍"],
["맛보기한", "맛보기〮ᄒᆞᆫ"],
["느낌이랄까", "닏믁이ᄅᆞᆯ가〯"],
["기분이구나", "기븐〮이로다"],
["말해", "ᄆᆞᆯᄒᆞ야"],
["사건", "일"],
["학교", "學校"],
];
function record(replacements, kind, from, to, count) {
if (count > 0) {
replacements.push({ kind, from, to, count });
}
}
function replaceLiteral(text, from, to, replacements) {
const count = text.split(from).length - 1;
if (count === 0) return text;
record(replacements, "lexicon", from, to, count);
return text.split(from).join(to);
}
function replaceRegex(text, pattern, to, replacements, kind, label) {
const matches = text.match(pattern);
const count = matches ? matches.length : 0;
const next = text.replace(pattern, to);
record(replacements, kind, label ?? String(pattern), typeof to === "string" ? to : "<rule>", count);
return next;
}
function protectSpans(input) {
const protectedSpans = [];
let text = input;
function protect(pattern) {
text = text.replace(pattern, (match) => {
const token = `\uE000${protectedSpans.length}\uE001`;
protectedSpans.push(match);
return token;
});
}
protect(/```[\s\S]*?```/g);
protect(/`[^`\n]*`/g);
protect(/\[[^\]\n]*\]\([^\s)]+(?:\s+"[^"]*")?\)/g);
protect(/\bhttps?:\/\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g);
protect(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g);
return { text, protectedSpans };
}
function restoreSpans(text, protectedSpans) {
let output = text;
protectedSpans.forEach((span, index) => {
output = output.replaceAll(`\uE000${index}\uE001`, span);
});
return output;
}
function convertToMiddleKoreanStyle(input) {
return createReport(input).output;
}
function createReport(input) {
if (typeof input !== "string") {
throw new TypeError("input must be a string");
}
const replacements = [];
const protectedInput = protectSpans(input);
let output = protectedInput.text;
output = replaceRegex(output, /(\d+)년/g, "$1年", replacements, "date", "년→年");
output = replaceRegex(output, /(\d+)월/g, "$1月", replacements, "date", "월→月");
output = replaceRegex(output, /(\d+)일/g, "$1日", replacements, "date", "일→日");
for (const [from, to] of LEXICON) {
output = replaceLiteral(output, from, to, replacements);
}
output = replaceRegex(output, /말하는/g, "ᄆᆞᆯᄒᆞᄂᆞᆫ", replacements, "ending", "말하는→ᄆᆞᆯᄒᆞᄂᆞᆫ");
output = replaceRegex(output, /공부했다〮?/g, "공부ᄒᆞ엿다〮", replacements, "ending", "공부했다→공부ᄒᆞ엿다〮");
output = replaceRegex(output, /했다〮?/g, "ᄒᆞ엿다〮", replacements, "ending", "했다→ᄒᆞ엿다〮");
output = replaceRegex(output, /하는(?=\s|[",.?!]|$)/g, "ᄒᆞᄂᆞᆫ", replacements, "ending", "하는→ᄒᆞᄂᆞᆫ");
output = replaceRegex(output, /된(?=\s|[",.?!]|$)/g, "ᄃᆞᆫ", replacements, "ending", "된→ᄃᆞᆫ");
output = replaceRegex(output, /것이냐(?=[.?!。]|$)/g, "것이냐〮", replacements, "ending", "것이냐→것이냐〮");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(은|는)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆫ", replacements, "particle", "은/는→ᄋᆞᆫ");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(을|를)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆯ", replacements, "particle", "을/를→ᄋᆞᆯ");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)에서(?=\s|[",.?!]|$)/g, "$1애", replacements, "particle", "에서→애");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)와(?=\s|[",.?!]|$)/g, "$1와", replacements, "particle", "와 보존");
output = output.replace(/\s+([,.;:?!])/g, "$1");
output = restoreSpans(output, protectedInput.protectedSpans);
return {
profile: PROFILE,
input,
output,
replacements,
contract: CONTRACT,
};
}
function parseArgs(argv) {
const parsed = {
format: "json",
inputMode: null,
text: undefined,
};
let sourceCount = 0;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--text") {
sourceCount += 1;
parsed.inputMode = "text";
parsed.text = argv[++index];
if (parsed.text === undefined) throw new Error("--text requires a value");
} else if (arg === "--file") {
sourceCount += 1;
parsed.inputMode = "file";
parsed.file = argv[++index];
if (!parsed.file) throw new Error("--file requires a path");
} else if (arg === "--stdin") {
sourceCount += 1;
parsed.inputMode = "stdin";
} else if (arg === "--format") {
parsed.format = argv[++index];
if (!parsed.format) throw new Error("--format requires a value");
} else if (arg === "--help" || arg === "-h") {
parsed.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!parsed.help && sourceCount !== 1) {
throw new Error("provide exactly one input source: --text, --file, or --stdin");
}
if (!["json", "text"].includes(parsed.format)) {
throw new Error(`unknown format: ${parsed.format}`);
}
return parsed;
}
function readInput(options) {
if (options.inputMode === "text") return options.text;
if (options.inputMode === "file") return fs.readFileSync(options.file, "utf8");
if (options.inputMode === "stdin") return fs.readFileSync(0, "utf8");
throw new Error("missing input source");
}
function helpText() {
return `Usage: node scripts/korean_middle_korean.js (--text TEXT | --file PATH | --stdin) [--format json|text]\n\nConverts Korean input into a deterministic Korean Middle Korean-style rewrite.\n`;
}
function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
process.stdout.write(helpText());
return;
}
const report = createReport(readInput(options));
if (options.format === "text") {
process.stdout.write(`${report.output}\n`);
} else {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = {
CONTRACT,
PROFILE,
convertToMiddleKoreanStyle,
createReport,
parseArgs,
main,
};