reverbo 0.1.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.
- package/README.md +102 -0
- package/bin/reverbo.js +5 -0
- package/package.json +35 -0
- package/src/api.js +142 -0
- package/src/cache.js +105 -0
- package/src/cli.js +641 -0
- package/src/config.js +81 -0
- package/src/login.js +80 -0
- package/src/outbox.js +264 -0
- package/src/paths.js +15 -0
- package/src/prompt.js +117 -0
- package/src/redact.js +108 -0
- package/src/render.js +164 -0
- package/src/settings.js +56 -0
- package/src/statusline.js +62 -0
- package/src/transcript.js +88 -0
- package/src/translate.js +144 -0
- package/src/wizard.js +93 -0
- package/src/worker.js +58 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// 상태줄 렌더 — 스펙 D2.
|
|
2
|
+
//
|
|
3
|
+
// 사실 ④ 로 COLUMNS·LINES 를 알 수 있으니 자르지 않고 접는다. 다만 한글은
|
|
4
|
+
// 터미널에서 두 칸을 먹어서, 글자 수로 접으면 화면을 넘는다. 의존성 0 이라
|
|
5
|
+
// East Asian Wide/Fullwidth 범위를 직접 판정한다.
|
|
6
|
+
|
|
7
|
+
const WIDE = [
|
|
8
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff],
|
|
9
|
+
[0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
|
|
10
|
+
[0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe6f],
|
|
11
|
+
[0xff00, 0xff60], [0xffe0, 0xffe6],
|
|
12
|
+
[0x1f300, 0x1f64f], [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function codeWidth(cp) {
|
|
16
|
+
for (const [lo, hi] of WIDE) if (cp >= lo && cp <= hi) return 2
|
|
17
|
+
return 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stringWidth(s) {
|
|
21
|
+
let w = 0
|
|
22
|
+
for (const ch of String(s)) w += codeWidth(ch.codePointAt(0))
|
|
23
|
+
return w
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// COLUMNS 는 실제로 글자가 보이는 폭보다 크다 — 테두리와 여백이 있다.
|
|
27
|
+
// 실측(2026-09-07): COLUMNS 로 124칸을 채운 줄이 화면에서 `…` 로 잘렸고
|
|
28
|
+
// 123칸은 살아남았다. 그 차이만큼 미리 비워 두면 어느 창 크기에서도
|
|
29
|
+
// 안 잘린다(창을 줄여도 테두리 폭은 그대로라 여백은 상수다).
|
|
30
|
+
//
|
|
31
|
+
// 덜 채우는 대가는 줄당 몇 칸이고, 안 비웠을 때의 대가는 문장 끝이
|
|
32
|
+
// 먹혀 배울 것이 사라지는 것이다. 비대칭이 크니 넉넉한 쪽으로 기운다.
|
|
33
|
+
const SAFE_MARGIN = 4
|
|
34
|
+
|
|
35
|
+
// wrap 은 「이 폭에 맞춰 접어라」만 안다. 여백은 화면 경계를 아는
|
|
36
|
+
// buildLines 의 몫이다 — 층을 섞으면 wrap(text, 4) 가 4칸으로 안 접힌다.
|
|
37
|
+
function fit(columns) {
|
|
38
|
+
return Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 접두 두 칸과 안전 여백을 뺀, 글자가 실제로 들어갈 폭.
|
|
42
|
+
// 아주 좁은 창에서 다 먹어버리면 아무것도 못 내므로 최소는 남긴다.
|
|
43
|
+
function usableWidth(columns, color) {
|
|
44
|
+
const prefix = color ? 0 : SOURCE_PREFIX.length // 색이 켜지면 접두가 없다
|
|
45
|
+
return Math.max(4, fit(columns) - SAFE_MARGIN - prefix)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 폭보다 긴 낱말은 글자에서 자른다. 자르지 않으면 무한루프가 난다.
|
|
49
|
+
function breakWord(word, width) {
|
|
50
|
+
const out = []
|
|
51
|
+
let cur = ''
|
|
52
|
+
let w = 0
|
|
53
|
+
for (const ch of word) {
|
|
54
|
+
const cw = codeWidth(ch.codePointAt(0))
|
|
55
|
+
if (w + cw > width && cur) { out.push(cur); cur = ''; w = 0 }
|
|
56
|
+
cur += ch
|
|
57
|
+
w += cw
|
|
58
|
+
}
|
|
59
|
+
if (cur) out.push(cur)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function wrap(text, columns) {
|
|
64
|
+
const width = fit(columns)
|
|
65
|
+
const out = []
|
|
66
|
+
for (const para of String(text || '').split('\n')) {
|
|
67
|
+
let cur = ''
|
|
68
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
69
|
+
const pieces = stringWidth(word) > width ? breakWord(word, width) : [word]
|
|
70
|
+
for (const piece of pieces) {
|
|
71
|
+
const next = cur ? `${cur} ${piece}` : piece
|
|
72
|
+
if (stringWidth(next) > width && cur) { out.push(cur); cur = piece }
|
|
73
|
+
else cur = next
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (cur) out.push(cur)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SOURCE_PREFIX = '─ '
|
|
82
|
+
const TARGET_PREFIX = ' '
|
|
83
|
+
// 접힌 줄에 표시를 다시 찍으면 한 문장이 여러 문장처럼 보인다. 이 표시는
|
|
84
|
+
// 「여기서 한 덩어리가 시작한다」는 뜻이라 첫 행에만 있어야 하고, 이어지는
|
|
85
|
+
// 행은 같은 폭만큼 들여써 글자가 세로로 맞게 한다.
|
|
86
|
+
const CONT_PREFIX = ' '.repeat(SOURCE_PREFIX.length)
|
|
87
|
+
|
|
88
|
+
// 줄임표가 붙을 한 칸을 비워 준다 — 안 그러면 그 한 칸 때문에 폭을 넘는다.
|
|
89
|
+
function clip(line, width) {
|
|
90
|
+
if (stringWidth(line) < width) return line
|
|
91
|
+
let out = ''
|
|
92
|
+
for (const ch of line) {
|
|
93
|
+
if (stringWidth(out + ch) > width - 1) break
|
|
94
|
+
out += ch
|
|
95
|
+
}
|
|
96
|
+
return out.trimEnd()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const withPrefix = (lines, first, cont) =>
|
|
100
|
+
lines.map((l, i) => (i === 0 ? first : cont) + l)
|
|
101
|
+
|
|
102
|
+
// DESIGN.md — 중성색 + 신호색 하나, accent 는 화면에 한 번.
|
|
103
|
+
// 색조를 늘리지 않고 명암으로 가른다: 원문은 방금 자기가 친 것이라 흐리게,
|
|
104
|
+
// 번역은 배우는 것이라 기본 밝기. 상태줄에 이미 다른 색이 있어도 안 싸운다.
|
|
105
|
+
//
|
|
106
|
+
// **색은 접은 뒤에 입힌다.** ANSI 코드가 폭 계산에 섞이면 줄이 화면을 넘는다.
|
|
107
|
+
const DIM = '\x1b[2m'
|
|
108
|
+
const ACCENT = '\x1b[33m'
|
|
109
|
+
const RESET = '\x1b[0m'
|
|
110
|
+
|
|
111
|
+
function paint(lines, { dim, accentFirst }) {
|
|
112
|
+
return lines.map((l, i) => {
|
|
113
|
+
// 첫 행의 표시만 accent 다 — 나머지는 본문과 같은 밝기로 둔다.
|
|
114
|
+
// 표시가 없으면 칠할 accent 도 없다 — 명암이 그 일을 대신한다.
|
|
115
|
+
if (i === 0 && accentFirst && l.startsWith(SOURCE_PREFIX)) {
|
|
116
|
+
const mark = l.slice(0, SOURCE_PREFIX.length)
|
|
117
|
+
const rest = l.slice(SOURCE_PREFIX.length)
|
|
118
|
+
return `${ACCENT}${mark}${RESET}${dim ? DIM : ''}${rest}${RESET}`
|
|
119
|
+
}
|
|
120
|
+
return `${dim ? DIM : ''}${l}${RESET}`
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildLines({ source, translated, columns, rows, showSource, maxRatio, color }) {
|
|
125
|
+
if (!translated) return [] // 번역이 없으면 원문만 띄우지 않는다. 화면만 먹는다.
|
|
126
|
+
// 색이 켜져 있으면 명암이 이미 원문과 번역을 가른다. 표시까지 두면
|
|
127
|
+
// 중복이고, 그 두 칸 때문에 모든 줄이 밀려 좁은 창에서 손해를 본다.
|
|
128
|
+
// 색이 꺼지면 표시가 유일한 단서라 남긴다.
|
|
129
|
+
const marks = color
|
|
130
|
+
? { source: '', target: '', cont: '' }
|
|
131
|
+
: { source: SOURCE_PREFIX, target: TARGET_PREFIX, cont: CONT_PREFIX }
|
|
132
|
+
const width = usableWidth(columns, color)
|
|
133
|
+
// 원문을 띄우는 방식 셋. 예전 설정의 true/false 도 그대로 받는다.
|
|
134
|
+
// full — 전부. 대조하기 좋지만 화면을 제일 많이 먹는다
|
|
135
|
+
// first — 한 행만. 원문은 방금 입력창에서 본 것이라 「어느 문장인지」만
|
|
136
|
+
// 알면 되는 사람에겐 이게 충분하고 화면이 절반으로 준다
|
|
137
|
+
// off — 번역만
|
|
138
|
+
const mode = showSource === true ? 'full' : showSource === false ? 'off' : showSource
|
|
139
|
+
|
|
140
|
+
let lines = []
|
|
141
|
+
if (mode === 'full') {
|
|
142
|
+
lines.push(...withPrefix(wrap(source, width), marks.source, marks.cont))
|
|
143
|
+
} else if (mode === 'first') {
|
|
144
|
+
const all = wrap(source, width)
|
|
145
|
+
// 뒤에 더 있다는 것을 알려야 한다 — 안 그러면 원문이 저게 전부인 줄 안다.
|
|
146
|
+
lines.push(marks.source + (all.length > 1 ? clip(all[0], width) + '…' : all[0] || ''))
|
|
147
|
+
}
|
|
148
|
+
const srcCount = lines.length
|
|
149
|
+
lines.push(...withPrefix(wrap(translated, width), marks.target, marks.cont))
|
|
150
|
+
|
|
151
|
+
const height = Number.isFinite(rows) && rows > 0 ? rows : 24
|
|
152
|
+
const ratio = Number.isFinite(maxRatio) && maxRatio > 0 && maxRatio <= 1 ? maxRatio : 0.25
|
|
153
|
+
const cap = Math.max(1, Math.floor(height * ratio))
|
|
154
|
+
if (lines.length > cap) {
|
|
155
|
+
lines = lines.slice(0, cap)
|
|
156
|
+
lines[lines.length - 1] = `${lines[lines.length - 1]}…`
|
|
157
|
+
}
|
|
158
|
+
if (!color) return lines
|
|
159
|
+
// 자를 자리를 정한 뒤에 칠한다 — 잘린 행에도 초기화가 붙어야 한다.
|
|
160
|
+
return [
|
|
161
|
+
...paint(lines.slice(0, srcCount), { dim: true, accentFirst: true }),
|
|
162
|
+
...paint(lines.slice(srcCount), { dim: false, accentFirst: false }),
|
|
163
|
+
]
|
|
164
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ~/.claude/settings.json 을 우리가 고칠 수 있는지 판정한다.
|
|
2
|
+
//
|
|
3
|
+
// **이건 우리 파일이 아니다.** 사람이 공들여 고쳐둔 파일이고, 이미 다른
|
|
4
|
+
// 상태줄이 붙어 있을 수 있다(kickbacks 를 쓰거나 자기 스크립트를 붙여둔
|
|
5
|
+
// 사람). 그래서 이 모듈은 「쓴다」가 아니라 「써도 되는지」만 정하고,
|
|
6
|
+
// 실제 쓰기는 호출부가 한다 — 판정을 테스트에서 전부 돌려볼 수 있게.
|
|
7
|
+
|
|
8
|
+
export const COMMAND = 'reverbo statusline'
|
|
9
|
+
|
|
10
|
+
// 이 값이 바뀌면 이미 붙여둔 사람의 설정과 어긋나 uninstall 이 그걸 남의
|
|
11
|
+
// 것으로 오인하고 안 떼어낸다. 바꾸려면 마이그레이션을 같이 생각해야 한다.
|
|
12
|
+
export const STATUS_LINE = {
|
|
13
|
+
type: 'command',
|
|
14
|
+
command: COMMAND,
|
|
15
|
+
// 갱신 트리거가 이벤트 기반이라 세션이 조용하면 안 돈다. 번역이 늦게
|
|
16
|
+
// 도착했을 때 화면에 반영될 기회를 이걸로 보장한다.
|
|
17
|
+
refreshInterval: 5,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 없는 파일과 빈 파일은 같게 본다. 그 외에는 객체로 파싱돼야만 손댄다 —
|
|
21
|
+
// 주석이 있거나 손상된 파일을 우리가 다시 쓰면 사람 설정이 사라진다.
|
|
22
|
+
function read(rawText) {
|
|
23
|
+
const text = String(rawText || '').trim()
|
|
24
|
+
if (!text) return { empty: true }
|
|
25
|
+
try {
|
|
26
|
+
const json = JSON.parse(text)
|
|
27
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) return { bad: true }
|
|
28
|
+
return { json }
|
|
29
|
+
} catch {
|
|
30
|
+
return { bad: true }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isOurs = (line) => line?.command === COMMAND
|
|
35
|
+
|
|
36
|
+
// 남의 상태줄이 무엇인지 사람에게 보여주려고 한 줄로 요약한다.
|
|
37
|
+
const describe = (line) => (typeof line === 'string' ? line : line?.command || JSON.stringify(line))
|
|
38
|
+
|
|
39
|
+
export function planInstall(rawText) {
|
|
40
|
+
const { empty, bad, json } = read(rawText)
|
|
41
|
+
if (bad) return { status: 'unparsable' }
|
|
42
|
+
if (empty) return { status: 'create', json: { statusLine: STATUS_LINE } }
|
|
43
|
+
if (isOurs(json.statusLine)) return { status: 'already' }
|
|
44
|
+
if (json.statusLine !== undefined) return { status: 'conflict', existing: describe(json.statusLine) }
|
|
45
|
+
return { status: 'add', json: { ...json, statusLine: STATUS_LINE } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function planUninstall(rawText) {
|
|
49
|
+
const { empty, bad, json } = read(rawText)
|
|
50
|
+
if (bad) return { status: 'unparsable' }
|
|
51
|
+
if (empty || json.statusLine === undefined) return { status: 'nothing' }
|
|
52
|
+
// 우리가 안 붙인 것을 우리가 떼면 안 된다.
|
|
53
|
+
if (!isOurs(json.statusLine)) return { status: 'foreign', existing: describe(json.statusLine) }
|
|
54
|
+
const { statusLine, ...rest } = json
|
|
55
|
+
return { status: 'remove', json: rest }
|
|
56
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 읽는 쪽 — 스펙 D4.
|
|
2
|
+
//
|
|
3
|
+
// 아무것도 기다리지 않는다. 사실 ⑤ 대로 실행 중에 새 트리거가 오면
|
|
4
|
+
// Claude Code 가 이 프로세스를 죽인다. 캐시 파일 하나를 읽고 끝낸다.
|
|
5
|
+
|
|
6
|
+
import { paths } from './paths.js'
|
|
7
|
+
import { buildLines } from './render.js'
|
|
8
|
+
|
|
9
|
+
export async function statusline({ input, env, deps }) {
|
|
10
|
+
try {
|
|
11
|
+
// I2 — session_id 로는 절대 못 찾는다. transcript.js 는 entry.promptId
|
|
12
|
+
// 로만 대조하고, session_id 는 그 값과 무관하다. 예전엔 이 자리에서
|
|
13
|
+
// session_id 로 떨어져 매번 워커를 띄우고 트랜스크립트를 읽어 놓고
|
|
14
|
+
// (최대 ~21MB) 결국 못 찾아 허탕만 쳤다. prompt_id 는 Claude Code
|
|
15
|
+
// v2.1.196 이상에서만 온다 — 그보다 낮은 버전에서는 여기서 조용히
|
|
16
|
+
// 빈 줄로 끝난다(M4 — status 가 이걸 안내한다).
|
|
17
|
+
const key = input?.prompt_id
|
|
18
|
+
if (!key) return ''
|
|
19
|
+
|
|
20
|
+
const p = paths(env)
|
|
21
|
+
const cfg = await deps.config(p.configFile)
|
|
22
|
+
|
|
23
|
+
const hit = await deps.readEntry({ dir: p.cacheDir, promptId: key })
|
|
24
|
+
// I1 — 우편함에 항목이 있다는 것 자체가 「이미 처리됐다」는 뜻이다.
|
|
25
|
+
// translated 가 빈 문자열(SAME, 이미 목표어)이어도 다시 워커를 띄우면
|
|
26
|
+
// 안 된다 — 그러면 잠금 TTL 이 지날 때마다 같은 프롬프트를 유료로
|
|
27
|
+
// 다시 번역하게 된다. null(실패)만 재시도 대상이고, 그건 애초에
|
|
28
|
+
// 캐시되지 않는다.
|
|
29
|
+
if (hit) {
|
|
30
|
+
if (!hit.translated) return ''
|
|
31
|
+
return buildLines({
|
|
32
|
+
source: hit.source,
|
|
33
|
+
translated: hit.translated,
|
|
34
|
+
columns: Number(env?.COLUMNS),
|
|
35
|
+
rows: Number(env?.LINES),
|
|
36
|
+
showSource: cfg.showSource,
|
|
37
|
+
maxRatio: cfg.maxRatio,
|
|
38
|
+
// NO_COLOR 는 관례다 — 설정보다 환경이 이긴다.
|
|
39
|
+
color: cfg.color && !env?.NO_COLOR,
|
|
40
|
+
}).join('\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 부를 곳이 없으면 워커를 띄우지 않는다. 「키를 넣어주세요」를 작업
|
|
44
|
+
// 화면에 띄우지도 않는다 — 상태는 `status` 로 본다.
|
|
45
|
+
if (!cfg.apiKey) return ''
|
|
46
|
+
if (!input?.transcript_path) return ''
|
|
47
|
+
if (await deps.acquireLock({ dir: p.cacheDir, promptId: key, now: deps.now() })) {
|
|
48
|
+
deps.spawnWorker({
|
|
49
|
+
prompt_id: key,
|
|
50
|
+
transcript_path: input.transcript_path,
|
|
51
|
+
// 서버에 쌓을 때 어디서 친 것인지 알려면 여기서 넘겨야 한다.
|
|
52
|
+
// 워커는 stdin JSON 을 못 본다.
|
|
53
|
+
repo: input && input.workspace && input.workspace.repo && input.workspace.repo.name,
|
|
54
|
+
// 제외 디렉터리 판정에만 쓴다. 기기를 안 떠난다.
|
|
55
|
+
cwd: (input && input.cwd) || (input && input.workspace && input.workspace.current_dir),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
return ''
|
|
59
|
+
} catch {
|
|
60
|
+
return '' // 스펙 D8
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 트랜스크립트에서 「사람이 친 문장」만 꺼낸다.
|
|
2
|
+
//
|
|
3
|
+
// 실측(2026-09-06, 85MB 파일): type:"user" 줄 1676개 중 사람이 친 것은
|
|
4
|
+
// 285개뿐이다. 도구 결과 1337 · isMeta 40 · 중단 12 · 슬래시 확장 2.
|
|
5
|
+
// 「마지막 user 줄」을 집으면 대부분 도구 출력 JSON 을 번역하게 된다.
|
|
6
|
+
//
|
|
7
|
+
// promptId 는 사람이 친 285개 전부에 있었다. 그래서 마지막을 집지 않고
|
|
8
|
+
// statusLine 이 준 prompt_id 로 정확히 찾는다.
|
|
9
|
+
|
|
10
|
+
// 사람이 안 쳤는데 사람 턴처럼 보이는 것들. 실측(2026-09-07, 트랜스크립트
|
|
11
|
+
// 12개 926줄)에서 53줄이 여기 걸린다 — 슬래시 명령 확장, `!` 로 친 셸
|
|
12
|
+
// 명령과 그 출력, 그리고 백그라운드 작업 알림.
|
|
13
|
+
//
|
|
14
|
+
// 이걸 안 거르면 도구 출력이 상태줄에 영어로 떠 있고, 그만큼 번역 비용도
|
|
15
|
+
// 나간다. 접두만 보고 판단하는 이유는 본문 어디에나 나올 수 있는 말이
|
|
16
|
+
// 아니라 **줄 맨 앞에 오는 표시**라서다. 닫는 `>` 까지 요구하는 이유는
|
|
17
|
+
// `<bash-input-format> 이건 무슨 뜻이야?` 같은 진짜 질문을 안 삼키려는 것이다.
|
|
18
|
+
const NOT_HUMAN_TAGS = [
|
|
19
|
+
'command-message', 'command-name', 'command-args',
|
|
20
|
+
'local-command-stdout', 'local-command-stderr',
|
|
21
|
+
'bash-input', 'bash-stdout', 'bash-stderr',
|
|
22
|
+
'task-notification', 'system-reminder',
|
|
23
|
+
]
|
|
24
|
+
const NOT_HUMAN_PREFIX = new RegExp(`^<(${NOT_HUMAN_TAGS.join('|')})>`)
|
|
25
|
+
const INTERRUPTED = /^\[Request interrupted/
|
|
26
|
+
const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
|
|
27
|
+
|
|
28
|
+
export function textOf(entry) {
|
|
29
|
+
const c = entry?.message?.content
|
|
30
|
+
const raw = typeof c === 'string'
|
|
31
|
+
? c
|
|
32
|
+
: Array.isArray(c)
|
|
33
|
+
? c.filter((p) => p?.type === 'text').map((p) => p.text || '').join('')
|
|
34
|
+
: ''
|
|
35
|
+
return raw.replace(SYSTEM_REMINDER, '').trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isHumanTurn(entry) {
|
|
39
|
+
if (!entry || entry.type !== 'user') return false
|
|
40
|
+
if (entry.isMeta || entry.isSidechain) return false
|
|
41
|
+
if (entry.toolUseResult !== undefined) return false
|
|
42
|
+
const c = entry.message?.content
|
|
43
|
+
if (Array.isArray(c) && c.some((p) => p?.type === 'tool_result')) return false
|
|
44
|
+
const t = textOf(entry)
|
|
45
|
+
if (!t) return false
|
|
46
|
+
if (NOT_HUMAN_PREFIX.test(t) || INTERRUPTED.test(t)) return false
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function findInChunk(chunk, promptId) {
|
|
51
|
+
const lines = chunk.split('\n')
|
|
52
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
53
|
+
const line = lines[i]
|
|
54
|
+
// 값싼 사전 검사. 대부분의 줄은 여기서 걸러져 JSON.parse 를 안 탄다.
|
|
55
|
+
if (!line || !line.includes(promptId)) continue
|
|
56
|
+
let entry
|
|
57
|
+
try { entry = JSON.parse(line) } catch { continue } // 앞이 잘린 첫 줄이 정상이다
|
|
58
|
+
if (entry.promptId !== promptId) continue
|
|
59
|
+
if (!isHumanTurn(entry)) continue
|
|
60
|
+
return textOf(entry)
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FIRST = 256 * 1024
|
|
66
|
+
const CAP = 16 * 1024 * 1024
|
|
67
|
+
|
|
68
|
+
export async function findPrompt({ path, promptId, open }) {
|
|
69
|
+
if (!path || !promptId) return null
|
|
70
|
+
let fh
|
|
71
|
+
try {
|
|
72
|
+
fh = await open(path, 'r')
|
|
73
|
+
const { size } = await fh.stat()
|
|
74
|
+
for (let want = FIRST; ; want *= 4) {
|
|
75
|
+
const len = Math.min(want, size)
|
|
76
|
+
const pos = size - len
|
|
77
|
+
const buf = Buffer.alloc(len)
|
|
78
|
+
await fh.read(buf, 0, len, pos)
|
|
79
|
+
const hit = findInChunk(buf.toString('utf8'), promptId)
|
|
80
|
+
if (hit) return hit
|
|
81
|
+
if (len >= size || want >= CAP) return null
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return null // 못 읽으면 빈 줄로 간다. 상태줄은 사람의 작업 화면이다.
|
|
85
|
+
} finally {
|
|
86
|
+
try { await fh?.close() } catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/translate.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// 번역 어댑터 — 스펙 D3.
|
|
2
|
+
//
|
|
3
|
+
// 아는 곳을 표로 둔다. 셋째(로컬 `claude -p`, 로컬 모델)를 붙일 때
|
|
4
|
+
// translate 의 제어 흐름을 고치는 게 아니라 PROVIDERS 에 한 줄을 더하면 된다.
|
|
5
|
+
//
|
|
6
|
+
// **어디로 보내는지가 이 파일의 유일한 개인정보 결정이다.** 프롬프트에는
|
|
7
|
+
// 코드·파일 경로·회사 맥락이 들어 있고, 그게 통째로 나간다.
|
|
8
|
+
// Anthropic 은 Claude Code 가 이미 그 문장을 보낸 곳이라 새 노출이 아니지만,
|
|
9
|
+
// **OpenAI 는 그 문장이 처음 가는 곳이다.** 기본을 OpenAI 로 둔 것은 사람의
|
|
10
|
+
// 결정이고, 그래서 `init` 이 그 사실을 말한다 — 고르는 건 사람이되 모르고
|
|
11
|
+
// 고르지는 않게.
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT = 8000
|
|
14
|
+
// M3 — 긴 입력에 좁은 출력 상한을 물리면 번역이 중간에서 잘리고, 잘린 채로
|
|
15
|
+
// 캐시돼 영구화된다(다시 안 부른다). 입력을 줄이고 출력 여유를 늘린다.
|
|
16
|
+
const MAX_INPUT = 2000
|
|
17
|
+
const MAX_OUTPUT = 2048
|
|
18
|
+
|
|
19
|
+
// 이미 목표어로 쓴 문장에 이 약속어를 받아 빈 줄로 지나간다. 별도 언어
|
|
20
|
+
// 감지기를 두지 않는다.
|
|
21
|
+
const SAME = 'SAME'
|
|
22
|
+
|
|
23
|
+
function prompt(text, from, to) {
|
|
24
|
+
return [
|
|
25
|
+
`Translate the following text from ${from} to ${to}.`,
|
|
26
|
+
`Reply with the translation only — no preface, no quotes, no explanation.`,
|
|
27
|
+
`If the text is already written in ${to}, reply with exactly ${SAME}.`,
|
|
28
|
+
'',
|
|
29
|
+
text,
|
|
30
|
+
].join('\n')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 두 곳이 같은 판정을 한다 — 응답에서 뽑아낸 글자를 무엇으로 볼 것인가.
|
|
34
|
+
// null ⟸ 모양이 이상하거나 비었다(실패로 보고 재시도 가능하게 둔다)
|
|
35
|
+
// '' ⟸ SAME(목표어라 번역이 없다는 확답, 다시 안 묻는다)
|
|
36
|
+
function verdict(raw) {
|
|
37
|
+
const text = String(raw || '').trim()
|
|
38
|
+
if (!text) return null
|
|
39
|
+
return text.toUpperCase() === SAME ? '' : text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Anthropic ──────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages'
|
|
45
|
+
const ANTHROPIC_VERSION = '2023-06-01'
|
|
46
|
+
|
|
47
|
+
export function buildAnthropicRequest({ text, from, to, model, apiKey }) {
|
|
48
|
+
return {
|
|
49
|
+
url: ANTHROPIC_ENDPOINT,
|
|
50
|
+
init: {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
'x-api-key': apiKey,
|
|
55
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
model,
|
|
59
|
+
max_tokens: MAX_OUTPUT,
|
|
60
|
+
messages: [{ role: 'user', content: prompt(text, from, to) }],
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseAnthropicResponse(json) {
|
|
67
|
+
const parts = json?.content
|
|
68
|
+
if (!Array.isArray(parts)) return null
|
|
69
|
+
return verdict(parts.filter((p) => p?.type === 'text').map((p) => p.text || '').join(''))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── OpenAI ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions'
|
|
75
|
+
|
|
76
|
+
export function buildOpenAIRequest({ text, from, to, model, apiKey }) {
|
|
77
|
+
return {
|
|
78
|
+
url: OPENAI_ENDPOINT,
|
|
79
|
+
init: {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'content-type': 'application/json',
|
|
83
|
+
authorization: `Bearer ${apiKey}`,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
model,
|
|
87
|
+
max_tokens: MAX_OUTPUT,
|
|
88
|
+
messages: [{ role: 'user', content: prompt(text, from, to) }],
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseOpenAIResponse(json) {
|
|
95
|
+
const content = json?.choices?.[0]?.message?.content
|
|
96
|
+
if (typeof content !== 'string') return null
|
|
97
|
+
return verdict(content)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── 표 ─────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export const PROVIDERS = {
|
|
103
|
+
anthropic: {
|
|
104
|
+
buildRequest: buildAnthropicRequest,
|
|
105
|
+
parseResponse: parseAnthropicResponse,
|
|
106
|
+
truncated: (json) => json?.stop_reason === 'max_tokens',
|
|
107
|
+
},
|
|
108
|
+
openai: {
|
|
109
|
+
buildRequest: buildOpenAIRequest,
|
|
110
|
+
parseResponse: parseOpenAIResponse,
|
|
111
|
+
truncated: (json) => json?.choices?.[0]?.finish_reason === 'length',
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 반환값은 셋 중 하나다 — string(번역문) · ''(SAME, 목표어라 번역이 없다는
|
|
116
|
+
// 확답) · null(무엇이든 실패, 잠금 TTL 이 지나면 재시도해야 한다).
|
|
117
|
+
// ''와 null 을 섞으면(과거 버전처럼) 「목표어라 다시 안 부른다」와
|
|
118
|
+
// 「실패했으니 다시 불러야 한다」를 워커가 구분할 수 없다 — I1.
|
|
119
|
+
export async function translate({
|
|
120
|
+
text, from, to, provider, model, apiKey, fetchImpl,
|
|
121
|
+
timeoutMs = DEFAULT_TIMEOUT,
|
|
122
|
+
}) {
|
|
123
|
+
const body = String(text || '').trim()
|
|
124
|
+
if (!body || !apiKey) return null
|
|
125
|
+
const impl = PROVIDERS[provider]
|
|
126
|
+
if (!impl) return null
|
|
127
|
+
|
|
128
|
+
const ac = new AbortController()
|
|
129
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
130
|
+
try {
|
|
131
|
+
const { url, init } = impl.buildRequest({ text: body.slice(0, MAX_INPUT), from, to, model, apiKey })
|
|
132
|
+
const res = await fetchImpl(url, { ...init, signal: ac.signal })
|
|
133
|
+
if (!res?.ok) return null
|
|
134
|
+
const json = await res.json()
|
|
135
|
+
// 잘린 번역을 캐시하면 영구히 잘린 채로 남는다(M3) — 실패로 취급해
|
|
136
|
+
// 재시도 가능하게 둔다.
|
|
137
|
+
if (impl.truncated(json)) return null
|
|
138
|
+
return impl.parseResponse(json)
|
|
139
|
+
} catch {
|
|
140
|
+
return null // 느리든 끊기든 — 실패는 재시도 가능해야 한다
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(timer)
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/wizard.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// 설정 마법사의 판정부. p10k 가 스타일을 그려놓고 고르게 하듯,
|
|
2
|
+
// 말로 설명하지 말고 **고른 값이 실제로 어떻게 보이는지** 그려준다.
|
|
3
|
+
//
|
|
4
|
+
// 화면·키 입력은 cli 가 맡고 여기는 순수 함수만 둔다 — 미리보기가
|
|
5
|
+
// 실제 렌더러를 그대로 쓰는지 테스트로 확인할 수 있어야 한다.
|
|
6
|
+
|
|
7
|
+
import { buildLines } from './render.js'
|
|
8
|
+
|
|
9
|
+
// 미리보기에 쓰는 예문. 짧으면 셋의 차이가 안 보이므로 접힐 만큼 길게 둔다.
|
|
10
|
+
// 언어쌍마다 예문이 달라야 고르는 의미가 있다. 방향까지 열쇠로 쓴다 —
|
|
11
|
+
// from 만 보면 ko→en 과 ko→ja 가 같은 그림이 된다.
|
|
12
|
+
const SENTENCES = {
|
|
13
|
+
ko: '코치 오프닝이 되묻지 않게 고치자. 몰아보기 영상에서만 회귀하는 것 같아',
|
|
14
|
+
en: "Let's fix the coach's opening so it stops asking back. It seems to regress only on compilation videos.",
|
|
15
|
+
ja: 'コーチの冒頭が聞き返さないように直そう。まとめ動画でだけ戻るようだ',
|
|
16
|
+
}
|
|
17
|
+
const FALLBACK = { from: 'ko', to: 'en' }
|
|
18
|
+
|
|
19
|
+
export const QUESTIONS = [
|
|
20
|
+
{
|
|
21
|
+
key: 'showSource',
|
|
22
|
+
title: '원문을 어떻게 띄울까요',
|
|
23
|
+
note: '원문은 방금 입력창에서 본 것이라, 안 띄우면 화면이 절반으로 줄어요.',
|
|
24
|
+
options: [
|
|
25
|
+
{ value: 'full', label: '전부' },
|
|
26
|
+
{ value: 'first', label: '첫 행만' },
|
|
27
|
+
{ value: 'off', label: '안 띄움' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: 'color',
|
|
32
|
+
title: '색으로 가를까요',
|
|
33
|
+
note: '원문은 흐리게, 번역은 기본 밝기예요. 색을 끄면 원문 앞에 줄표가 붙어요.',
|
|
34
|
+
options: [
|
|
35
|
+
{ value: true, label: '색으로 가르기' },
|
|
36
|
+
{ value: false, label: '색 없이' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'langs',
|
|
41
|
+
title: '어느 쪽으로 번역할까요',
|
|
42
|
+
note: '',
|
|
43
|
+
options: [
|
|
44
|
+
{ value: 'ko:en', label: '한국어 → 영어' },
|
|
45
|
+
{ value: 'en:ko', label: '영어 → 한국어' },
|
|
46
|
+
{ value: 'ko:ja', label: '한국어 → 일본어' },
|
|
47
|
+
{ value: 'ja:ko', label: '일본어 → 한국어' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
// 고른 값을 얹은 설정으로 실제 렌더러를 돌린다. 설명이 아니라 실물이라
|
|
53
|
+
// 렌더러가 바뀌면 미리보기도 같이 바뀐다.
|
|
54
|
+
export function previewOf(question, value, config, columns) {
|
|
55
|
+
const cfg = applyAnswer(config, question.key, value)
|
|
56
|
+
const from = SENTENCES[cfg.from] ? cfg.from : FALLBACK.from
|
|
57
|
+
const to = SENTENCES[cfg.to] ? cfg.to : FALLBACK.to
|
|
58
|
+
return buildLines({
|
|
59
|
+
source: SENTENCES[from],
|
|
60
|
+
translated: SENTENCES[to],
|
|
61
|
+
columns,
|
|
62
|
+
rows: 200, // 미리보기는 세로 상한에 안 걸리게 — 차이를 다 보여준다
|
|
63
|
+
maxRatio: 1,
|
|
64
|
+
showSource: cfg.showSource,
|
|
65
|
+
color: cfg.color,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function applyAnswer(config, key, value) {
|
|
70
|
+
if (key === 'langs') {
|
|
71
|
+
const [from, to] = String(value).split(':')
|
|
72
|
+
if (!from || !to) return { ...config }
|
|
73
|
+
return { ...config, from, to }
|
|
74
|
+
}
|
|
75
|
+
if (!QUESTIONS.some((q) => q.key === key)) return { ...config }
|
|
76
|
+
return { ...config, [key]: value }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 방향키는 raw 모드에서 여러 바이트로 오고 터미널마다 다르다. 스페이스로
|
|
80
|
+
// 다음 값으로 넘기고 엔터로 고르는 편이 어디서나 같게 동작한다.
|
|
81
|
+
export function nextValue(question, current) {
|
|
82
|
+
const i = question.options.findIndex((o) => o.value === current)
|
|
83
|
+
return question.options[(i + 1) % question.options.length].value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 지금 설정에서 이 물음의 값이 무엇인지.
|
|
87
|
+
export function currentValue(question, config) {
|
|
88
|
+
if (question.key === 'langs') return `${config.from}:${config.to}`
|
|
89
|
+
const v = config[question.key]
|
|
90
|
+
// 예전 설정의 true/false 를 새 이름으로 옮겨 준다.
|
|
91
|
+
if (question.key === 'showSource') return v === true ? 'full' : v === false ? 'off' : v
|
|
92
|
+
return v
|
|
93
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 쓰는 쪽 — 스펙 D4.
|
|
2
|
+
//
|
|
3
|
+
// 별도 프로세스로 돈다. 죽어도 상태줄과 무관하고, 상태줄이 죽어도
|
|
4
|
+
// 이쪽은 계속 돈다(detached).
|
|
5
|
+
|
|
6
|
+
import { paths } from './paths.js'
|
|
7
|
+
|
|
8
|
+
export async function work({ input, env, deps }) {
|
|
9
|
+
try {
|
|
10
|
+
const p = paths(env)
|
|
11
|
+
const cfg = await deps.config(p.configFile)
|
|
12
|
+
|
|
13
|
+
const source = await deps.findPrompt({ path: input.transcript_path, promptId: input.prompt_id })
|
|
14
|
+
if (!source) return
|
|
15
|
+
|
|
16
|
+
// 관문 G2 — 걸리면 제공처에도 안 보낸다. 번역·캐시·전송 전부 건너뛴다.
|
|
17
|
+
// 반드시 번역 호출 앞이어야 한다 — 순서가 뒤바뀌면 비밀값이 이미
|
|
18
|
+
// 번역 제공처(OpenAI·Anthropic)로 나간 뒤라 안전장치가 의미가 없다.
|
|
19
|
+
if (deps.shouldSkip({ text: source, cwd: input.cwd, exclude: cfg.exclude })) return
|
|
20
|
+
|
|
21
|
+
const translated = await deps.translate({
|
|
22
|
+
text: source, from: cfg.from, to: cfg.to,
|
|
23
|
+
provider: cfg.provider, model: cfg.model, apiKey: cfg.apiKey,
|
|
24
|
+
})
|
|
25
|
+
// I1 — null 은 「실패」다. 캐시에 안 남겨야 잠금 TTL 이 지난 뒤 다시
|
|
26
|
+
// 시도된다. ''(SAME)는 「이미 목표어라 번역이 없다」는 확답이라 아래로
|
|
27
|
+
// 내려가 캐시에 남는다 — 그래야 매 새로고침마다 같은 유료 호출이
|
|
28
|
+
// 반복되지 않는다.
|
|
29
|
+
if (translated === null) return
|
|
30
|
+
|
|
31
|
+
await deps.writeEntry({ dir: p.cacheDir, promptId: input.prompt_id, entry: { source, translated } })
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// A안 채택 — 서버는 from·to 를 필수로 보고 없으면 400, outbox 의
|
|
35
|
+
// classify 는 400 을 'discard' 로 봐서 조용히 사라진다. 여기서
|
|
36
|
+
// 비우지 않고 「기본값으로 채운다」쪽을 골랐다: cfg 는 config.js 의
|
|
37
|
+
// normalize 를 거쳐 나오고, normalize 는 raw.from/raw.to 가 빈
|
|
38
|
+
// 문자열이거나 없으면 DEFAULTS.from='ko'/DEFAULTS.to='en' 으로
|
|
39
|
+
// 채워 넣는다 — 그래서 cfg.from·cfg.to 는 이 시점에 절대 비지 않는다.
|
|
40
|
+
//
|
|
41
|
+
// D2(리뷰) — cfg 를 통째로 안 실어 보낸다. cfg 에는 exclude(사람의
|
|
42
|
+
// 절대 디렉터리 경로 목록)와 apiKey(번역 제공처 키)가 들어 있는데
|
|
43
|
+
// 그건 서버가 알 필요도, 알아서도 안 되는 값이다. 훅이 받을 수 있는
|
|
44
|
+
// 값을 여기서 좁혀 두면 Task 6 이 uploadPrompt 를 그대로 이어 붙여도
|
|
45
|
+
// 실수로 태울 값 자체가 없다.
|
|
46
|
+
await deps.upload({
|
|
47
|
+
promptId: input.prompt_id, source, translated, repo: input.repo,
|
|
48
|
+
from: cfg.from, to: cfg.to, token: cfg.token,
|
|
49
|
+
})
|
|
50
|
+
} catch {
|
|
51
|
+
// 전송이 실패해도 상태줄 학습은 이미 성립했다.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await deps.prune({ dir: p.cacheDir })
|
|
55
|
+
} catch {
|
|
56
|
+
// 스펙 D8 — 워커도 조용히 죽는다
|
|
57
|
+
}
|
|
58
|
+
}
|