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/src/config.js ADDED
@@ -0,0 +1,81 @@
1
+ // 설정 — 스펙 D3. 키는 여기 0600 으로 저장되거나 환경변수에서 온다.
2
+ // 값이 이상해도 기본값으로 떨어진다. 상태줄이 설정 때문에 멈추면 안 된다.
3
+
4
+ // 아는 곳마다 기본 모델과 키를 읽을 환경변수가 다르다. 여기 한 곳에만
5
+ // 적어서, 제공처를 바꾸면 나머지가 따라오게 한다.
6
+ export const MODELS = {
7
+ openai: 'gpt-4o-mini',
8
+ anthropic: 'claude-haiku-4-5',
9
+ }
10
+
11
+ export const KEY_ENV = {
12
+ openai: 'OPENAI_API_KEY',
13
+ anthropic: 'ANTHROPIC_API_KEY',
14
+ }
15
+
16
+ export const DEFAULTS = {
17
+ // 사람의 결정(2026-09-07). Anthropic 은 Claude Code 가 이미 그 문장을
18
+ // 보낸 곳이라 새 노출이 아니지만 OpenAI 는 처음 가는 곳이다 — 그래서
19
+ // init 이 그 사실을 말한다.
20
+ provider: 'openai',
21
+ model: '', // 비어 있으면 제공처의 기본 모델을 쓴다
22
+ from: 'ko',
23
+ to: 'en',
24
+ showSource: 'full', // 'full' | 'first' | 'off' — 마법사가 정한다
25
+ color: true,
26
+ maxRatio: 0.25, // 우리가 내는 행이 터미널 높이에서 차지할 상한
27
+ apiKey: '',
28
+ token: '', // 로그인하면 채워진다. 없으면 아무것도 안 올라간다
29
+ exclude: [], // 이 디렉터리 아래에서는 아무것도 안 올린다 (G2)
30
+ }
31
+
32
+ // 설정이 모델을 지정하지 않았으면 제공처에서 끌어온다. 모르는 제공처는
33
+ // 기본 제공처로 떨어진다 — 설정이 손상돼도 번역이 멈추지 않게.
34
+ export function modelFor(provider, model) {
35
+ return model || MODELS[provider] || MODELS[DEFAULTS.provider]
36
+ }
37
+
38
+ export function keyEnvFor(provider) {
39
+ return KEY_ENV[provider] || KEY_ENV[DEFAULTS.provider]
40
+ }
41
+
42
+ const STRINGS = ['provider', 'model', 'from', 'to', 'apiKey', 'token']
43
+
44
+ export function normalize(raw) {
45
+ // D3(리뷰) — { ...DEFAULTS } 는 얕은 복사라 exclude 는 DEFAULTS.exclude
46
+ // 배열 자체를 그대로 물고 나온다. 여기서 매번 새 배열로 떼어놓지 않으면
47
+ // 호출자가 반환값의 exclude 를 push 하는 순간 DEFAULTS 가 오염되고, 그
48
+ // 프로세스의 이후 모든 normalize 호출이 남의 제외 경로를 물고 나온다.
49
+ const out = { ...DEFAULTS, exclude: [...DEFAULTS.exclude] }
50
+ if (!raw || typeof raw !== 'object') return out
51
+ for (const k of STRINGS) if (typeof raw[k] === 'string' && raw[k]) out[k] = raw[k]
52
+ // 예전 설정 파일에는 true/false 가 들어 있다 — 새 이름으로 옮겨 받는다.
53
+ if (raw.showSource === true) out.showSource = 'full'
54
+ else if (raw.showSource === false) out.showSource = 'off'
55
+ else if (['full', 'first', 'off'].includes(raw.showSource)) out.showSource = raw.showSource
56
+ if (typeof raw.color === 'boolean') out.color = raw.color
57
+ if (typeof raw.maxRatio === 'number' && raw.maxRatio > 0 && raw.maxRatio <= 1) out.maxRatio = raw.maxRatio
58
+ // 문자열이 아닌 항목(숫자·null 등)은 조용히 버린다 — redact.js 의
59
+ // isExcluded 와 같은 결이다. 설정 파일이 손상돼도 나머지 제외 경로는 살아남는다.
60
+ if (Array.isArray(raw.exclude)) out.exclude = raw.exclude.filter((p) => typeof p === 'string' && p)
61
+ return out
62
+ }
63
+
64
+ export async function loadConfig({ file, readFile }) {
65
+ try {
66
+ return normalize(JSON.parse(await readFile(file, 'utf8')))
67
+ } catch {
68
+ return { ...DEFAULTS }
69
+ }
70
+ }
71
+
72
+ export async function saveConfig({ file, config, mkdir, writeFile, chmod }) {
73
+ const dir = file.slice(0, file.lastIndexOf('/'))
74
+ // I5 — ~/.reverbo 자체도 프롬프트 원문이 든 캐시를 담는 디렉터리다.
75
+ await mkdir(dir, { recursive: true, mode: 0o700 })
76
+ await writeFile(file, JSON.stringify(normalize(config), null, 2), { mode: 0o600 })
77
+ // M2 — writeFile 의 mode 는 파일을 「새로 만들 때」만 적용된다. 이미
78
+ // 0644 로 있던 설정 파일에 다시 init 을 돌려도 그대로 0644 로 남는다 —
79
+ // 명시적으로 한 번 더 조인다.
80
+ await chmod(file, 0o600)
81
+ }
package/src/login.js ADDED
@@ -0,0 +1,80 @@
1
+ // 기기 인증 흐름 — 스펙 D1.
2
+ //
3
+ // CLI 는 브라우저가 없어 구글 로그인을 직접 못 한다. 사람이 보는 코드와
4
+ // 기기가 쥐는 코드를 가르는 것이 이 흐름의 핵심이다 — 사람이 보는 코드는
5
+ // 어깨너머로 보이거나 화면 공유에 찍힐 수 있는데, 그것만으로는 남이
6
+ // 가져갈 수 없다.
7
+ //
8
+ // 그런데 사람이 보는 코드가 짧다 보니(`TERM-XXXXXXXX`) 어깨너머로 보이는
9
+ // 것과는 다른 경쟁도 생긴다 — 남이 먼저 그 코드를 승인해버리는 것. 이건
10
+ // 서버의 SQL 만으로는 못 막는다. 그래서 서버는 승인 응답에 email 을
11
+ // 함께 실어 보내고, 여기서 승인 직후 그 이메일을 사람에게 보여준다 —
12
+ // 「이 계정이 맞나」를 사람이 그 자리에서 확인할 수 있어야 방어가 성립한다.
13
+ // 옛 서버는 email 을 안 줄 수 있으니 있을 때만 보여준다.
14
+ //
15
+ // 판단은 순수하게, I/O 는 전부 주입받는다(`start`·`poll`·`show`·`sleep`·
16
+ // `now`) — sleep 을 목으로 즉시 반환시켜도 now 로 시간을 따로 흘려보내면
17
+ // 마감 판정을 실제로 기다리지 않고 테스트할 수 있다.
18
+
19
+ const DEFAULT_MAX_MS = 10 * 60 * 1000
20
+ const DEFAULT_INTERVAL = 2
21
+
22
+ // 화면에 못 쓴 것과 로그인이 안 된 것은 다르다. 이 함수 안에서 값을
23
+ // 만드는 게 아니라 화면에 얹는 것뿐이라, 여기서 던져도 승인·값 자체는
24
+ // 이미 유효하다 — 그 실패로 성공한 로그인을 버리면 안 된다.
25
+ function showSafely(show, payload) {
26
+ try {
27
+ show(payload)
28
+ } catch {
29
+ // 조용히 무시한다 — cli.js 가 EPIPE 를 막는 것과 같은 결이다.
30
+ }
31
+ }
32
+
33
+ export async function login({ start, poll, show, sleep, now, maxMs = DEFAULT_MAX_MS }) {
34
+ try {
35
+ const started = await start()
36
+ if (!started) return { ok: false, reason: 'unreachable' }
37
+
38
+ // 사람에게는 사용자코드만 보여준다. 기기코드가 화면에 찍히면 둘로
39
+ // 가른 의미가 없어진다.
40
+ showSafely(show, { kind: 'code', userCode: started.userCode, expiresIn: started.expiresIn })
41
+
42
+ // status 를 api.js 의 정규화만 믿지 않는 것과 같은 이유로, interval 도
43
+ // 그대로 믿지 않는다. 음수·0·NaN·문자열이 오면 setTimeout 이 즉시
44
+ // 반환돼 마감까지 서버를 왕복 속도로 두들긴다 — 여기서 막는다.
45
+ const rawInterval = Number(started.interval)
46
+ const safeInterval = Number.isFinite(rawInterval) && rawInterval > 0 ? rawInterval : DEFAULT_INTERVAL
47
+ // 간격이 마감보다 길면 의미가 없다 — 첫 sleep 에서 이미 마감을 넘긴다.
48
+ const intervalMs = Math.min(safeInterval * 1000, maxMs)
49
+
50
+ const deadline = now() + maxMs
51
+ // now() 만 믿고 마감을 재면, 시계가 뒤로 가는 순간(NTP 스텝·절전 복귀)
52
+ // 영원히 안 온다. 시도 횟수라는 두 번째 잣대를 나란히 둔다 — 시계와
53
+ // 무관하게 동작해서 이 순간에도 반드시 끝난다.
54
+ const maxAttempts = Math.ceil(maxMs / intervalMs) + 1
55
+ let attempts = 0
56
+
57
+ for (;;) {
58
+ const r = await poll({ deviceCode: started.deviceCode })
59
+
60
+ if (r.status === 'approved') {
61
+ // 짧은 코드가 남에게 먼저 채여도, 사람이 이 이메일을 보고
62
+ // 「내 계정이 아니다」를 그 자리에서 알아챌 수 있다.
63
+ if (r.email) showSafely(show, { kind: 'linked', email: r.email })
64
+ return { ok: true, token: r.token }
65
+ }
66
+ if (r.status === 'expired') return { ok: false, reason: 'expired' }
67
+
68
+ // pending·error·그 밖의 모르는 status 를 전부 같게 다룬다 — 네트워크가
69
+ // 잠깐 끊긴 것과 아직 승인 안 된 것, 서버가 앞으로 늘릴 상태를 여기서
70
+ // 미리 판단하지 않는다. 어느 경우든 기다렸다 다시 묻고, 시계·시도
71
+ // 횟수 둘 중 하나라도 마감을 넘기면 반드시 빠져나온다.
72
+ attempts += 1
73
+ if (now() >= deadline || attempts >= maxAttempts) return { ok: false, reason: 'timeout' }
74
+ await sleep(intervalMs)
75
+ }
76
+ } catch {
77
+ // 절대 던지지 않는다 — 사람 눈에는 「연결이 안 됐다」와 같다.
78
+ return { ok: false, reason: 'unreachable' }
79
+ }
80
+ }
package/src/outbox.js ADDED
@@ -0,0 +1,264 @@
1
+ // 못 올린 것을 잃지 않는다 — 스펙 D4.
2
+ //
3
+ // 비행기에서 친 프롬프트도 상태줄 학습은 이미 됐고, 착륙하면 따라붙는다.
4
+ //
5
+ // 리뷰 판정 R3(2026-09-08) — 처음엔 shouldRetry(status)→boolean 로 4xx 를
6
+ // 전부 버렸다. 근거는 「죽은 로그인 하나가 outbox 를 영원히 돌린다」였는데
7
+ // 성립하지 않는다: drain 은 첫 재시도 실패에서 멈추므로 drain 당 요청은
8
+ // 1회고, append 의 상한(max)이 무한 증식을 이미 막는다.
9
+ //
10
+ // 그런데 401·403 을 그냥 버리면 **영구 소실**이다 — worker.js 가 캐시에
11
+ // 먼저 쓰지만 캐시는 최신 200개만 남기고 다시 올리는 경로가 없다. 로그인이
12
+ // 만료된 동안은 upload 가 계속 401 을 받는데, 그걸 버리면 outbox 에 아무
13
+ // 흔적도 안 남는다. 401·403 은 본문이 틀린 4xx 와 성질이 다르다 — 사람이
14
+ // `reverbo login` 을 다시 하면 복구되는 조건이다. 그래서 boolean 둘을
15
+ // 조합하는 대신 상태를 명시한다.
16
+ //
17
+ // 402 도 같은 이유로 따로 뒀다('blocked'). 구독이 끊기면 서버가 402 를
18
+ // 내는데, 그건 401 과 달리 재로그인으로 안 풀린다 — 「로그인이 만료됐어요」
19
+ // 라고 말하면 사람은 될 리 없는 일을 반복한다. 줄은 남기고 멈춘다.
20
+
21
+ const DEFAULT_MAX = 1000
22
+
23
+ // 한 번의 drain 이 잠금을 쥐고 있어도 되는 시간. 아래 LOCK_TTL(60초)보다
24
+ // 확실히 짧아야 한다 — 넘기면 다음 워커가 「죽은 잠금」으로 보고 빼앗고,
25
+ // 그 사이 새로 쌓인 줄이 이 함수의 마지막 writeAtomic 에 덮여 사라진다.
26
+ //
27
+ // 줄 수로는 못 막는다. 줄당 소요가 0.3초일 수도 있고 api.js 의 시간 초과
28
+ // (10초)를 다 쓸 수도 있어서, 「몇 줄까지」가 시간을 안 정해준다. 그래서
29
+ // 시간으로 끊는다: 마지막 줄이 예산 끝에 시작해 10초를 다 써도 30초라
30
+ // TTL 안쪽이다.
31
+ const DEFAULT_BUDGET = 20_000
32
+
33
+ // I5 와 같은 이유다(cache.js) — 이 파일은 서버에 못 보낸 사람의 프롬프트
34
+ // 원문을 그대로 담는다. 오히려 캐시보다 더 오래 남는다: 캐시는 최신
35
+ // 200개만 남기지만 outbox 는 성공해서 지워질 때까지, 로그인이 죽어 있는
36
+ // 동안은 계속 쌓인다. 공용 기기에서 다른 계정이 못 읽게 0600.
37
+ const FILE_MODE = 0o600
38
+
39
+ // 재시도할 실패·로그인 문제·구독 문제·그냥 버릴 실패를 가른다.
40
+ export function classify(status) {
41
+ if (!status) return 'retry' // 네트워크 실패
42
+ if (status >= 200 && status < 300) return 'sent'
43
+ if (status === 429 || status >= 500) return 'retry' // 잠깐 기다리거나 우리 잘못
44
+ if (status === 401 || status === 403) return 'auth' // 재로그인하면 복구된다
45
+ // 구독이 끊긴 것이다. 버리면 사람이 친 문장이 아무 말 없이 사라지고,
46
+ // 'auth' 로 보면 「다시 로그인하면 된다」가 되는데 재로그인으로는 안 풀린다.
47
+ // 남겨두고 멈추면 나중에 구독했을 때 밀린 것이 전부 올라간다 — 그동안
48
+ // 무한히 붇는 것은 append 의 상한(max)이 막는다.
49
+ if (status === 402) return 'blocked'
50
+ return 'discard' // 본문이 틀린 4xx — 몇 번을 보내도 같다
51
+ }
52
+
53
+ async function readLines(file, readFile) {
54
+ try {
55
+ return String(await readFile(file, 'utf8')).split('\n').filter(Boolean)
56
+ } catch {
57
+ // 파일이 없거나(ENOENT) 못 읽으면 쌓인 게 없는 것으로 친다.
58
+ return []
59
+ }
60
+ }
61
+
62
+ // 임시 파일에 쓰고 이름을 바꾼다 — cache.js 의 writeEntry 와 같은 방식.
63
+ // append/drain 은 매번 파일 전체를 다시 쓰므로, 쓰는 도중 죽으면 최대
64
+ // 999줄이 잘릴 수 있다(마지막 한 줄이 아니다). rename 은 원자적이라 읽는
65
+ // 쪽은 옛 파일 전체 아니면 새 파일 전체만 본다 — 반쯤 쓰인 파일을 안 만난다.
66
+ // rename 은 모드를 바꾸지 않으니 tmp 에 준 0600 이 그대로 넘어간다.
67
+ //
68
+ // 실패하면(쓰기든 rename 이든) tmp 를 지운다. outbox 는 파일이 하나뿐이라
69
+ // cache.js 의 prune 같은 나이 기준 청소기를 새로 배선하는 게 과하다 —
70
+ // 쓴 사람이 그 자리에서 치우는 편이 단순하다. 못 지워도(권한 등) 던지지
71
+ // 않는다 — 청소 실패로 쌓기·비우기 자체가 죽으면 안 된다.
72
+ async function writeAtomic(file, content, writeFile, rename, unlink) {
73
+ const tmp = `${file}.tmp`
74
+ try {
75
+ await writeFile(tmp, content, { mode: FILE_MODE })
76
+ await rename(tmp, file)
77
+ } catch (err) {
78
+ try { await unlink(tmp) } catch {}
79
+ throw err // 호출자(append/drain)가 이미 감싸고 있다
80
+ }
81
+ }
82
+
83
+ export async function append({ file, entry, readFile, writeFile, rename, unlink, max = DEFAULT_MAX }) {
84
+ try {
85
+ const lines = await readLines(file, readFile)
86
+ lines.push(JSON.stringify(entry))
87
+ // 상한을 넘으면 오래된 것부터 버린다. 학습은 이미 상태줄에서 끝났으니
88
+ // 못 올린 기록 몇 개보다 사람 디스크가 중요하다.
89
+ const kept = lines.slice(Math.max(0, lines.length - max))
90
+ await writeAtomic(file, kept.join('\n') + '\n', writeFile, rename, unlink)
91
+ } catch {
92
+ // 못 쌓아도 제품은 계속 돈다 — 워커가 부르는 자리라 던지면 안 된다.
93
+ // 쓰다 실패해도 원본은 rename 전이라 안 바뀐 채로 남는다.
94
+ }
95
+ }
96
+
97
+ export async function drain({
98
+ file, send, readFile, writeFile, rename, unlink,
99
+ now = () => Date.now(), budgetMs = DEFAULT_BUDGET,
100
+ }) {
101
+ const lines = await readLines(file, readFile)
102
+ if (lines.length === 0) return { sent: 0, kept: 0, stopped: null }
103
+
104
+ const started = now()
105
+ let sent = 0
106
+ let i = 0
107
+ let stopped = null
108
+ for (; i < lines.length; i++) {
109
+ let entry
110
+ try {
111
+ entry = JSON.parse(lines[i])
112
+ } catch {
113
+ continue // 깨진 줄이다 — 사람이 편집했거나 쓰다 죽었다. 버리고 다음 줄로.
114
+ }
115
+ // 예산을 넘겼으면 보내기 전에 멈춘다. 나머지는 파일에 그대로 남아
116
+ // 다음 판에 간다 — 잠금을 놓는 것이 여기서 더 급하다.
117
+ //
118
+ // 첫 줄은 예산과 무관하게 한 번 보낸다. 안 그러면 예산이 0 이거나
119
+ // 시계가 튄 순간 아무것도 안 보내는 판이 반복돼 영원히 안 줄어든다.
120
+ if (i > 0 && now() - started >= budgetMs) {
121
+ stopped = 'retry'
122
+ break
123
+ }
124
+ let res
125
+ try {
126
+ res = await send(entry)
127
+ } catch {
128
+ res = { ok: false, status: null } // 네트워크 실패는 상태 없음으로 취급
129
+ }
130
+ const kind = res && res.ok ? 'sent' : classify(res && res.status)
131
+ if (kind === 'sent') {
132
+ sent++
133
+ continue
134
+ }
135
+ if (kind === 'discard') continue // 본문이 틀린 4xx — 버리고 다음 줄로
136
+ // 'retry'·'auth'·'blocked' 는 여기서 멈춘다 — 순서를 지키고, 막힌 이유가
137
+ // 뒤에도 그대로 적용될 가능성이 높다(로그인 만료면 뒤도 다 401 이고,
138
+ // 구독이 없으면 뒤도 다 402 다). 멈춘 자리부터 파일에 그대로 남는다.
139
+ stopped = kind
140
+ break
141
+ }
142
+
143
+ const rest = lines.slice(i)
144
+ try {
145
+ await writeAtomic(file, rest.length ? rest.join('\n') + '\n' : '', writeFile, rename, unlink)
146
+ } catch {
147
+ // 못 써도 던지지 않는다 — rename 전이면 원본이 그대로라 다음 drain 이
148
+ // 같은 줄을 다시 보낼 뿐, 이미 보낸 것을 잃지도 중복 집계하지도 않는다.
149
+ }
150
+ return { sent, kept: rest.length, stopped }
151
+ }
152
+
153
+ // ─── 잠금 ────────────────────────────────────────────────────────────────
154
+ //
155
+ // 워커는 프롬프트마다 새 프로세스로 뜬다. 사람이 빠르게 두 번 치면 두
156
+ // 워커가 같은 outbox 파일을 동시에 만지고, 서로의 read-modify-write 를
157
+ // 덮어쓴다 — 위의 tmp+rename 은 「쓰다 잘림」만 막지 이건 못 막는다.
158
+ // 나중에 rename 한 쪽이 이기고 먼저 쓴 쪽의 줄은 흔적도 없이 사라진다.
159
+ //
160
+ // cache.js 의 acquireLock 을 그대로 못 쓰는 이유 둘:
161
+ // · 그건 promptId 단위라 「서로 다른 프롬프트의 워커 둘」을 못 가른다.
162
+ // 여기서 지켜야 하는 건 파일 하나다.
163
+ // · 그건 스스로 안 푼다 — prune 이 TTL 로 치운다. 그런데 prune 이 도는
164
+ // 곳은 캐시 디렉터리뿐이고, 여기 구간은 짧으니 끝나면 바로 놓아야
165
+ // 다음 워커가 안 기다린다.
166
+ //
167
+ // wx(있으면 실패하는 원자적 생성)로 잡는 방식만 cache.js 에서 빌린다.
168
+
169
+ // 비우기(drain)는 잠금을 쥔 채로 서버를 왕복한다 — 밀린 것이 많으면
170
+ // 길어진다. 그래서 「죽었다」로 보는 시간이 캐시 잠금(60초)만큼 넉넉하다.
171
+ const LOCK_TTL = 60_000
172
+ const LOCK_TRIES = 40
173
+ const LOCK_WAIT = 250
174
+
175
+ const lockPath = (file) => `${file}.lock`
176
+
177
+ // 잠금 파일 한 줄은 `<시각> <주인>` 이다. 앞은 죽은 잠금 판정에, 뒤는
178
+ // 놓을 때 「내 것이 맞나」 확인에 쓴다.
179
+ function readLock(raw) {
180
+ const line = String(raw ?? '').trim()
181
+ const gap = line.indexOf(' ')
182
+ return gap < 0
183
+ ? { at: Number(line), owner: '' }
184
+ : { at: Number(line.slice(0, gap)), owner: line.slice(gap + 1) }
185
+ }
186
+
187
+ export async function lockOutbox({
188
+ file, id, now, sleep, readFile, writeFile, mkdir, unlink,
189
+ tries = LOCK_TRIES, waitMs = LOCK_WAIT, ttlMs = LOCK_TTL,
190
+ }) {
191
+ const path = lockPath(file)
192
+ const cut = file.lastIndexOf('/')
193
+ if (mkdir && cut > 0) {
194
+ try { await mkdir(file.slice(0, cut), { recursive: true, mode: 0o700 }) } catch {}
195
+ }
196
+
197
+ const grab = async () => {
198
+ try {
199
+ await writeFile(path, `${now()} ${id}`, { flag: 'wx', mode: FILE_MODE })
200
+ return true
201
+ } catch {
202
+ return false // 이미 누가 쥐고 있다
203
+ }
204
+ }
205
+
206
+ for (let i = 0; i < tries; i++) {
207
+ if (await grab()) return true
208
+ // 워커가 죽으면 잠금이 남는다. 시각을 넣어 오래된 것은 치우고 다시 잡는다.
209
+ let cleared = false
210
+ try {
211
+ const { at } = readLock(await readFile(path, 'utf8'))
212
+ if (!Number.isFinite(at) || now() - at >= ttlMs) {
213
+ await unlink(path)
214
+ cleared = true
215
+ }
216
+ } catch {
217
+ // 그새 누가 놓았거나 못 읽는다 — 다음 판에 다시 잡으러 간다
218
+ }
219
+ if (cleared && (await grab())) return true
220
+ // 마지막 판에는 안 잔다 — 어차피 다시 안 잡아본다. 한 번만 시도하는
221
+ // 자리(tries: 1)가 250ms 를 헛되이 기다리던 자리다.
222
+ if (i < tries - 1) await sleep(waitMs)
223
+ }
224
+ return false
225
+ }
226
+
227
+ // 남의 잠금은 안 지운다. A 가 TTL 을 넘겨 B 가 치우고 새로 잡은 뒤에 A 가
228
+ // 늦게 끝나 지우면, B 는 잠금을 잃은 줄도 모르는 채 C 가 들어와 둘이 같은
229
+ // 파일을 동시에 만진다 — 안전장치가 그대로 위험 요인이 되는 자리다.
230
+ export async function unlockOutbox({ file, id, readFile, unlink }) {
231
+ try {
232
+ const { owner } = readLock(await readFile(lockPath(file), 'utf8'))
233
+ if (owner !== id) return // 내 것이 아니다 — 그냥 둔다
234
+ await unlink(lockPath(file))
235
+ } catch {
236
+ // 이미 없거나 못 읽는다. 남아 있으면 TTL 이 치운다.
237
+ }
238
+ }
239
+
240
+ // 잠금을 끝내 못 잡았을 때의 마지막 수단. 여기서 그냥 포기하면 그
241
+ // 프롬프트는 사라진다 — 캐시는 최신 200개만 남기고 다시 올리는 경로가 없다.
242
+ //
243
+ // **안전망이라고 믿으면 안 된다. 여기 온 줄은 거의 항상 묻힌다.**
244
+ // 여기 닿으려면 잠금을 10초 넘게 못 잡아야 하는데, 그렇게 오래 쥐고 있는
245
+ // 쪽은 서버를 왕복 중인 drain 뿐이다. 그리고 drain 의 rename 은 그 왕복이
246
+ // 끝난 **뒤에** 온다 — 이어붙이는 순간이 drain 의 읽기~rename 창 안쪽일
247
+ // 확률이 압도적이고, 그 rename 이 이 줄을 덮어 지운다.
248
+ //
249
+ // 그래도 남겨둔 이유는 하나다. 확실히 버리는 것보다는 낫다 — drain 이
250
+ // 그새 끝났거나, 잠금을 쥔 쪽이 비우기가 아니었으면 이 줄은 살아남아
251
+ // 다음 워커나 `reverbo sync` 가 보낸다.
252
+ //
253
+ // 제대로 고치려면 넘치는 줄을 다른 파일(`outbox.overflow`)에 쌓고 다음
254
+ // drain 이 그걸 먼저 합치게 해야 한다 — rename 이 그 파일을 안 건드리니
255
+ // 묻힐 창이 없다. 이번 범위 밖이라 안 했다.
256
+ //
257
+ // 상한(max)은 여기서 안 본다. 다음 번 정상 append 가 넘친 만큼 잘라낸다.
258
+ export async function appendRaw({ file, entry, writeFile }) {
259
+ try {
260
+ await writeFile(file, JSON.stringify(entry) + '\n', { flag: 'a', mode: FILE_MODE })
261
+ } catch {
262
+ // 못 쌓아도 제품은 계속 돈다 — 워커가 부르는 자리라 던지면 안 된다.
263
+ }
264
+ }
package/src/paths.js ADDED
@@ -0,0 +1,15 @@
1
+ // 경로 계산은 순수 함수로 둔다. 테스트가 임시 디렉터리를 만들 필요가 없다.
2
+ export function paths(env = process.env) {
3
+ const home = env.HOME || env.USERPROFILE || '.'
4
+ const base = `${home}/.reverbo`
5
+ return {
6
+ base,
7
+ configFile: `${base}/config.json`,
8
+ cacheDir: `${base}/cache`,
9
+ outbox: `${base}/outbox.jsonl`,
10
+ // 마지막 비우기가 구독 때문에 멈췄다는 표시. `reverbo status` 가 이걸
11
+ // 읽는다 — 진단하려고 치는 명령이 서버 응답을 기다리게 만들지 않으려고,
12
+ // 그때그때 물어보는 대신 마지막에 본 것을 적어 둔다.
13
+ blocked: `${base}/outbox.blocked`,
14
+ }
15
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,117 @@
1
+ // 터미널에서 비밀값을 받는다 — 화면에 안 찍고.
2
+ //
3
+ // 인자로 받으면(`OPENAI_API_KEY=sk-... reverbo init`) 키가
4
+ // `~/.zsh_history` 에 평문으로 남는다. 설정 파일을 0600 으로 조여놓고
5
+ // 히스토리에 흘리면 의미가 없다.
6
+ //
7
+ // I/O 는 주입받는다. 실제 TTY 없이도 판정을 전부 돌려볼 수 있어야 한다.
8
+
9
+ const ENTER = ['\r', '\n']
10
+ const CTRL_C = '\x03'
11
+ const BACKSPACE = ['\x7f', '\b']
12
+
13
+ export function askSecret({ input, output, question }) {
14
+ // 물어볼 상대가 없으면 묻지 않는다. 영영 기다리는 것이 제일 나쁘다 —
15
+ // statusLine 이 부르는 경로가 아니어도 사람이 파이프로 부를 수 있다.
16
+ if (!input?.isTTY) return Promise.resolve('')
17
+
18
+ return new Promise((resolve) => {
19
+ let buf = ''
20
+ let done = false
21
+
22
+ const finish = (value) => {
23
+ if (done) return
24
+ done = true
25
+ input.removeListener('data', onData)
26
+ try { input.setRawMode(false) } catch {}
27
+ try { input.pause() } catch {}
28
+ output.write('\n')
29
+ resolve(value)
30
+ }
31
+
32
+ const onData = (chunk) => {
33
+ for (const ch of String(chunk)) {
34
+ if (ENTER.includes(ch)) return finish(buf.trim())
35
+ // 나가려는 사람을 붙잡아두지 않는다. 받은 것은 버린다.
36
+ if (ch === CTRL_C) return finish('')
37
+ if (BACKSPACE.includes(ch)) { buf = buf.slice(0, -1); continue }
38
+ buf += ch
39
+ }
40
+ }
41
+
42
+ try {
43
+ output.write(question)
44
+ input.setRawMode(true)
45
+ input.resume()
46
+ input.setEncoding?.('utf8')
47
+ input.on('data', onData)
48
+ } catch {
49
+ // raw 모드를 못 켜는 환경이면 조용히 포기한다 — 에코가 켜진 채로
50
+ // 비밀값을 받느니 안 받는 게 낫다.
51
+ done = true
52
+ resolve('')
53
+ }
54
+ })
55
+ }
56
+
57
+ // 미리보기를 그려놓고 고르게 한다. p10k 가 스타일을 보여주고 고르게 하는
58
+ // 것과 같은 생각이다 — 「원문을 전부 띄울까」는 말로 설명하는 것보다
59
+ // 실제로 어떻게 보이는지 보여주는 편이 빠르다.
60
+ //
61
+ // 방향키를 안 쓴다. raw 모드에서 여러 바이트로 오고 터미널마다 달라서,
62
+ // 스페이스로 넘기고 엔터로 고르는 편이 어디서나 같게 동작한다.
63
+ export function askChoice({ input, output, title, note, options, current, next, preview }) {
64
+ if (!input?.isTTY) return Promise.resolve(undefined)
65
+
66
+ return new Promise((resolve) => {
67
+ let value = options.some((o) => o.value === current) ? current : options[0].value
68
+ let done = false
69
+ let painted = 0
70
+
71
+ const draw = () => {
72
+ // 앞서 그린 만큼 지우고 다시 그린다 — 화면이 아래로 흐르지 않게.
73
+ if (painted) output.write(`\x1b[${painted}A\x1b[0J`)
74
+ const lines = [
75
+ `\x1b[1m${title}\x1b[0m`,
76
+ ...(note ? [`\x1b[2m${note}\x1b[0m`] : []),
77
+ '',
78
+ ...options.map((o) => (o.value === value ? `\x1b[33m❯\x1b[0m ${o.label}` : `\x1b[2m ${o.label}\x1b[0m`)),
79
+ '',
80
+ ...preview(value),
81
+ '',
82
+ '\x1b[2m스페이스로 넘기고 엔터로 골라요\x1b[0m',
83
+ ]
84
+ output.write(lines.join('\n') + '\n')
85
+ painted = lines.length
86
+ }
87
+
88
+ const finish = (picked) => {
89
+ if (done) return
90
+ done = true
91
+ input.removeListener('data', onData)
92
+ try { input.setRawMode(false) } catch {}
93
+ try { input.pause() } catch {}
94
+ output.write('\n')
95
+ resolve(picked)
96
+ }
97
+
98
+ const onData = (chunk) => {
99
+ for (const ch of String(chunk)) {
100
+ if (ENTER.includes(ch)) return finish(value)
101
+ if (ch === CTRL_C) return finish(undefined)
102
+ if (ch === ' ') { value = next(value); draw() }
103
+ }
104
+ }
105
+
106
+ try {
107
+ draw()
108
+ input.setRawMode(true)
109
+ input.resume()
110
+ input.setEncoding?.('utf8')
111
+ input.on('data', onData)
112
+ } catch {
113
+ done = true
114
+ resolve(undefined)
115
+ }
116
+ })
117
+ }
package/src/redact.js ADDED
@@ -0,0 +1,108 @@
1
+ // 관문 G2 — 스펙 2026-09-08-terminal-prompt-sync-design.md D5.
2
+ //
3
+ // 원문을 서버에 쌓는 것을 허용한 근거가 「본인만 쓴다 + 지울 수 있다」였고,
4
+ // 로그인을 만드는 순간 그 전제가 깨진다. 걸리면 그 프롬프트는 번역도
5
+ // 전송도 안 한다 — 제공처에도 안 보낸다.
6
+ //
7
+ // 정규식으로 확실히 잡히는 것만 본다. 「회사 고유명사」나 「사업 맥락」은
8
+ // 여기서 못 잡고, 그건 경로 제외가 맡는다.
9
+ //
10
+ // 놓치는 것(false negative)이 잡는 것보다 위험하다 — 비밀값이 새면
11
+ // 되돌릴 수 없고, 반대로 멀쩡한 문장을 거르면 번역 한 줄을 잃을 뿐이다.
12
+ // 그래서 규칙마다 최소 길이를 두되, 애매하면 거르는 쪽으로 기운다.
13
+
14
+ const RULES = [
15
+ ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
16
+ ['anthropic', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
17
+ ['openai', /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}/],
18
+ ['github', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{40,}/],
19
+ ['aws', /\bAKIA[0-9A-Z]{16}\b/],
20
+ ['slack', /\bxox[baprs]-[A-Za-z0-9-]{20,}/],
21
+ // 문자 집합에 `-`·`_` 가 들어가면 후행 \b 가 안 선다 — 둘 다 \W/\w 판정이
22
+ // 엇갈려서, 키가 하필 `-` 로 끝나면(구글 키는 실제로 그럴 수 있다)
23
+ // 「단어 경계」자체가 안 생겨 못 잡는다(D3, 실측: 39자 키가 `-` 로 끝나면
24
+ // null). `(?![A-Za-z0-9_-])` 로 바꿔 "다음이 같은 문자 집합이 아니다"를
25
+ // 직접 묻는다. 길이도 `{35}` 대신 `{35,}` 로 — 더 긴 키를 잘라먹지 않는다.
26
+ ['google', /\bAIza[A-Za-z0-9_-]{35,}(?![A-Za-z0-9_-])/],
27
+ // 판정 R1 — 리뷰가 찾은 빈 종류. 이 repo 자체가 POSTGRES_URL 과 Stripe 를
28
+ // 쓰니 접속 문자열·라이브 키는 실제로 붙여넣을 만한 것이다.
29
+ //
30
+ // scheme://user:pass@host 형태만 본다 — 자격증명이 실제로 박혀 있을 때만
31
+ // 걸리게 해서 평범한 https://example.com 이나 포트만 붙은 URL은 통과시킨다.
32
+ ['connection-string', /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/:@]+:[^\s/:@]+@[^\s/]+/],
33
+ // JWT 는 header.payload.signature 세 토막이고, header·payload 는 JSON 을
34
+ // base64url 인코딩한 거라 거의 항상 `ey`(= `{"` 의 인코딩)로 시작한다.
35
+ // 이 두 토막이 `ey` 로 시작하는 것만으로 오탐 위험이 낮다.
36
+ ['jwt', /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}/],
37
+ // Stripe 는 라이브 키만 본다 — sk_test_/rk_test_ 는 돈이 안 움직인다.
38
+ ['stripe', /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/],
39
+ // Authorization 헤더를 그대로 붙여넣는 경우. "Bearer" 뒤에 20자 이상
40
+ // 이어지는 토큰 문자가 없으면(예: 평범한 문장 속 "Bearer bonds") 안 걸린다.
41
+ ['bearer', /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/i],
42
+ // Slack 인커밍 웹훅은 URL 자체가 비밀값이다 — 알면 그 채널에 바로 쓸 수 있다.
43
+ ['slack-webhook', /\bhttps:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/],
44
+ // 이름이 비밀을 가리키고 값이 충분히 긴 대입만 본다. 짧으면 예시일 때가
45
+ // 많다. 앞에 `(?<![A-Za-z])` 를 두는 이유는 두 가지다 — `DATABASE_PASSWORD`
46
+ // 처럼 밑줄 뒤에 오는 실제 이름은 잡되(밑줄은 글자가 아니니 통과),
47
+ // `monkey: ...` 처럼 단어 안쪽의 `key` 는 안 잡는다(D6, 실측:
48
+ // "monkey: src/components/Button/index.tsx 를 봐" 가 assignment 로 걸렸었음).
49
+ // 뒤따르는 `=`/`:` 와 20자 이상의 값도 오탐을 막는 데 같이 쓰인다.
50
+ ['assignment', /(?<![A-Za-z])(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)S?\s*[=:]\s*["']?[A-Za-z0-9/+_.-]{20,}/i],
51
+ ]
52
+
53
+ export function findSecret(text) {
54
+ const t = String(text ?? '')
55
+ if (!t) return null
56
+ for (const [name, re] of RULES) if (re.test(t)) return name
57
+ return null
58
+ }
59
+
60
+ // 경로 하나를 정규식으로. 별표만 와일드카드로 보고 나머지는 글자 그대로다.
61
+ // `*` 를 기준으로 쪼개 각 조각을 escape 한 뒤 `.*` 로 다시 잇는다.
62
+ function toMatcher(pattern, home) {
63
+ const expanded = (pattern.startsWith('~') ? home + pattern.slice(1) : pattern)
64
+ // 끝 슬래시를 지운다 — 안 지우면 `^...acme/(?:/|$)` 가 `//` 를 요구해서
65
+ // 사람이 습관대로 붙인 슬래시 하나 때문에 제외가 조용히 무효가 된다(D4).
66
+ .replace(/\/+$/, '')
67
+ const escaped = expanded
68
+ .split('*')
69
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
70
+ .join('.*')
71
+ // 디렉터리 경계까지 요구한다 — acme 가 acme-public 을 막으면 안 된다.
72
+ // 뒤에 `/` 가 오거나 문자열이 거기서 끝나야 진짜 그 디렉터리다.
73
+ //
74
+ // 대소문자는 안 가린다(D5) — macOS 기본 파일시스템 자체가 대소문자를
75
+ // 안 가리므로 실제로 같은 디렉터리인데 표기만 다르면 놓칠 수 있다.
76
+ // 리눅스에서는 그만큼 과잉 차단이 되지만, 이 층에서 과잉 차단은 안전한
77
+ // 방향이다(놓치는 것이 잡는 것보다 위험하다는 원칙과 같은 결).
78
+ return new RegExp('^' + escaped + '(?:/|$)', 'i')
79
+ }
80
+
81
+ export function isExcluded(cwd, patterns, home = process.env.HOME || '') {
82
+ // 제외 목록은 사용자 설정 파일에서 그대로 오는 값이라 문자열이 아닌
83
+ // 항목(숫자·객체 등)이 섞일 수 있다. 그런 항목만 조용히 건너뛴다(D2) —
84
+ // 관문에서 던지면 그 뒤로 아무 판정도 안 되고 그냥 흘러갈 수 있다.
85
+ const list = Array.isArray(patterns) ? patterns.filter((p) => typeof p === 'string' && p) : []
86
+ if (list.length === 0) return false
87
+ // 제외 목록이 있는데 어디서 친 건지 모르면 **막는다.** 예전엔 그냥
88
+ // 흘려보냈는데, 그건 상태줄이 cwd 를 못 받는 판(Claude Code 가 cwd 도
89
+ // workspace.current_dir 도 안 주는 경우)에서 제외를 적어둔 사람의
90
+ // 프롬프트가 조용히 나간다는 뜻이다. 목록은 사람이 일부러 정한 값이라
91
+ // 모를 때는 안 나가는 쪽이 그 사람의 뜻에 가깝다 — 놓치는 것이 잡는
92
+ // 것보다 위험하다는 이 파일의 원칙과 같은 방향이다.
93
+ if (!cwd) return true
94
+ return list.some((p) => toMatcher(p, home).test(cwd))
95
+ }
96
+
97
+ // 둘 다 걸리면 경로가 먼저다 — 그 디렉터리에서는 아무것도 안 본다는 뜻이라
98
+ // 무엇이 들었는지 말할 필요도 없다.
99
+ //
100
+ // 인자를 통째로 빠뜨리거나 null 을 줘도 던지면 안 된다(D1) — 상태줄·워커가
101
+ // 부르는 자리라 여기서 던지면 호출부의 catch 유무에 따라 막아야 할
102
+ // 프롬프트가 그냥 흘러가는 방향으로 깨진다. 매개변수 기본값 `= {}` 는
103
+ // `undefined` 일 때만 적용되고 `null` 은 못 막아서 `opts || {}` 로 한 번 더 막는다.
104
+ export function shouldSkip(opts) {
105
+ const { text, cwd, exclude, home } = opts || {}
106
+ if (isExcluded(cwd, exclude, home)) return 'excluded-path'
107
+ return findSecret(text)
108
+ }