@cliphijack/santaclaude 1.0.10 → 1.0.12
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/package.json +1 -1
- package/santaclaude.js +51 -16
package/package.json
CHANGED
package/santaclaude.js
CHANGED
|
@@ -106,33 +106,57 @@ function findTranscript(cwd) {
|
|
|
106
106
|
return files[0]; // 가장 최근 수정 = 활성 세션
|
|
107
107
|
} catch (e) { return null; }
|
|
108
108
|
}
|
|
109
|
-
// JSONL
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
// 파일 꼬리(최근 maxBytes)만 읽음 — 수MB JSONL 통째로 안 열고 끝부분만(첫 잘린 줄 버림)
|
|
110
|
+
function readTail(file, maxBytes) {
|
|
111
|
+
try {
|
|
112
|
+
const size = fs.statSync(file).size; const start = Math.max(0, size - maxBytes); const len = size - start;
|
|
113
|
+
const fd = fs.openSync(file, 'r'); const buf = Buffer.alloc(len); fs.readSync(fd, buf, 0, len, start); fs.closeSync(fd);
|
|
114
|
+
let s = buf.toString('utf8'); if (start > 0) { const nl = s.indexOf('\n'); if (nl >= 0) s = s.slice(nl + 1); } // 중간부터 읽었으면 깨진 첫 줄 버림
|
|
115
|
+
return s.split('\n');
|
|
116
|
+
} catch (e) { return []; }
|
|
117
|
+
}
|
|
118
|
+
// 오프셋 이후 새로 늘어난 줄만 읽음(라이브 델타) — 완전한 줄까지만, 다음 오프셋 반환
|
|
119
|
+
function readDelta(file, from) {
|
|
120
|
+
try {
|
|
121
|
+
const size = fs.statSync(file).size; if (size <= from) return { lines: [], off: from };
|
|
122
|
+
const len = size - from; const fd = fs.openSync(file, 'r'); const buf = Buffer.alloc(len); fs.readSync(fd, buf, 0, len, from); fs.closeSync(fd);
|
|
123
|
+
const s = buf.toString('utf8'); const lastNl = s.lastIndexOf('\n'); if (lastNl < 0) return { lines: [], off: from }; // 완전한 줄 없음 → 다음 틱에
|
|
124
|
+
const complete = s.slice(0, lastNl);
|
|
125
|
+
return { lines: complete.split('\n'), off: from + Buffer.byteLength(complete, 'utf8') + 1 };
|
|
126
|
+
} catch (e) { return { lines: [], off: from }; }
|
|
127
|
+
}
|
|
128
|
+
// JSONL 줄들 → user/assistant 턴 (툴콜=칩, 첨부이미지=data URL)
|
|
129
|
+
function linesToTurns(lines) {
|
|
130
|
+
const out = []; let imgBudget = 8 * 1024 * 1024; // 전체 이미지 base64 합계 상한(모바일 보호)
|
|
131
|
+
function grabImgs(c) { const out2 = []; if (!Array.isArray(c)) return out2; for (const b of c) { if (b && b.type === 'image' && b.source) { const s = b.source; if (s.type === 'base64' && s.data && imgBudget > s.data.length) { imgBudget -= s.data.length; out2.push('data:' + (s.media_type || 'image/png') + ';base64,' + s.data); } else if (s.type === 'url' && s.url) out2.push(String(s.url)); } } return out2; }
|
|
113
132
|
for (const line of lines) {
|
|
114
133
|
if (!line) continue; let o; try { o = JSON.parse(line); } catch (e) { continue; }
|
|
115
134
|
if (o.type !== 'user' && o.type !== 'assistant') continue;
|
|
116
135
|
const m = o.message; if (!m) continue; const c = m.content;
|
|
117
136
|
if (o.type === 'user') {
|
|
118
|
-
let text = '';
|
|
137
|
+
let text = ''; const imgs = grabImgs(c);
|
|
119
138
|
if (typeof c === 'string') text = c;
|
|
120
|
-
else if (Array.isArray(c)) { const t = c.filter((b) => b && b.type === 'text').map((b) => b.text); if (!t.length) continue; text = t.join('\n'); } //
|
|
121
|
-
text = String(text || '').trim();
|
|
122
|
-
if (/^<(command-name|local-command|command-message|command-args)/.test(text)) continue; // 슬래시명령 메타 스킵
|
|
123
|
-
|
|
139
|
+
else if (Array.isArray(c)) { const t = c.filter((b) => b && b.type === 'text').map((b) => b.text); if (!t.length && !imgs.length) continue; text = t.join('\n'); } // 텍스트도 이미지도 없으면(tool_result만) 스킵
|
|
140
|
+
text = String(text || '').trim();
|
|
141
|
+
if (text && /^<(command-name|local-command|command-message|command-args)/.test(text)) continue; // 슬래시명령 메타 스킵
|
|
142
|
+
if (!text && !imgs.length) continue;
|
|
143
|
+
out.push({ role: 'user', text: text.slice(0, 8000), imgs });
|
|
124
144
|
} else {
|
|
125
|
-
let text = '', tools = [];
|
|
145
|
+
let text = '', tools = []; const imgs = grabImgs(c);
|
|
126
146
|
if (typeof c === 'string') text = c;
|
|
127
147
|
else if (Array.isArray(c)) for (const b of c) {
|
|
128
148
|
if (b && b.type === 'text' && b.text) text += (text ? '\n' : '') + b.text;
|
|
129
149
|
else if (b && b.type === 'tool_use') { const inp = b.input || {}; const hint = inp.command ? String(inp.command) : (inp.file_path ? String(inp.file_path).split('/').pop() : (inp.path ? String(inp.path) : '')); tools.push((b.name || 'tool') + (hint ? ': ' + hint.slice(0, 70) : '')); }
|
|
130
150
|
}
|
|
131
|
-
text = String(text || '').trim(); if (!text && !tools.length) continue;
|
|
132
|
-
out.push({ role: 'assistant', text: text.slice(0, 8000), tools });
|
|
151
|
+
text = String(text || '').trim(); if (!text && !tools.length && !imgs.length) continue;
|
|
152
|
+
out.push({ role: 'assistant', text: text.slice(0, 8000), tools, imgs });
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
|
-
return out
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
// 파일 꼬리(최근 2MB)만 읽어 최근 maxTurns개 (seed용)
|
|
158
|
+
function parseTurns(file, maxTurns) {
|
|
159
|
+
return linesToTurns(readTail(file, 2 * 1024 * 1024)).slice(-Math.max(10, Math.min(200, maxTurns || 60)));
|
|
136
160
|
}
|
|
137
161
|
|
|
138
162
|
async function post(api, p, body) {
|
|
@@ -251,6 +275,7 @@ async function run(conf) {
|
|
|
251
275
|
const session = String(pane).split(':')[0];
|
|
252
276
|
const claudeCmd = conf.claudeCmd || 'claude --dangerously-skip-permissions';
|
|
253
277
|
let warned = false; // 중복방지는 서버가 책임 (claim 시 next_fire 원자적 전진) — 클라 영구셋은 반복예약을 영구차단하므로 안 둠
|
|
278
|
+
let txState = { until: 0, off: 0, file: null, win: '' }; // 💬 루돌프톡 라이브: 무장시각·읽은 오프셋·세션파일·창
|
|
254
279
|
|
|
255
280
|
cleanOldImages();
|
|
256
281
|
console.log(`🛷 SantaClaude 커넥터 가동 — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
|
|
@@ -362,14 +387,16 @@ async function run(conf) {
|
|
|
362
387
|
if (rr > 0) args.push('-y', String(Math.max(10, Math.min(200, rr))));
|
|
363
388
|
if (args.length > 3) { try { execFileSync('tmux', args); console.log(` 📐 리사이즈 ${w} → 폭${c || '그대로'}${rr ? '×높이' + rr : ''}`); } catch (e) {} }
|
|
364
389
|
} else if (cmd.action === 'transcript') {
|
|
365
|
-
|
|
390
|
+
if (cmd.live) { txState.until = Date.now() + 45000; return; } // keepalive — 라이브 모드만 연장(seed 안 함, 초경량)
|
|
391
|
+
// 💬 seed: 전체내역(JSONL 꼬리) 읽어 웹에 전달 + 라이브 모드 무장(이후 델타는 화면틱에 묻어감)
|
|
366
392
|
const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
|
|
367
393
|
let cwd = os.homedir(); try { const pc = execFileSync('tmux', ['display-message', '-t', w, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); if (pc) cwd = pc; } catch (e) {}
|
|
368
394
|
const file = findTranscript(cwd);
|
|
369
395
|
let turns = [], found = !!file;
|
|
370
396
|
if (file) turns = parseTurns(file, parseInt(cmd.n) || 60);
|
|
371
397
|
try { await post(api, '/api/transcript', { token, window: cmd.name || '', turns, found }); } catch (e) {}
|
|
372
|
-
|
|
398
|
+
txState = { until: Date.now() + 45000, off: file ? (function () { try { return fs.statSync(file).size; } catch (e) { return 0; } })() : 0, file: file, win: cmd.name || '' }; // seed 시점 파일끝=오프셋 기준
|
|
399
|
+
console.log(` 💬 대화내역 ${turns.length}턴 → 웹${found ? ' (라이브 ON)' : ' (트랜스크립트 못 찾음)'}`);
|
|
373
400
|
}
|
|
374
401
|
} catch (e) { console.warn(` ⚠️ 제어 실패(${cmd.action} ${cmd.name}): ${e.message}`); }
|
|
375
402
|
}
|
|
@@ -470,13 +497,21 @@ async function run(conf) {
|
|
|
470
497
|
// 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
|
|
471
498
|
let lastScr = '';
|
|
472
499
|
function sendScreen() { if (!ws || ws.readyState !== 1) return; let cur; try { cur = JSON.stringify(captureAll(session)); } catch (e) { return; } if (cur === lastScr) return; lastScr = cur; try { ws.send(JSON.stringify({ type: 'screen', screens: JSON.parse(cur) })); } catch (e) {} }
|
|
500
|
+
// 💬 루돌프톡 라이브 델타 — 화면틱에 묻어감. 라이브 무장 중 + JSONL 늘었을 때만 새 턴 전송(우리 서버 추가비용 0에 수렴)
|
|
501
|
+
function sendTxDelta() {
|
|
502
|
+
if (!ws || ws.readyState !== 1) return; if (Date.now() > txState.until || !txState.file) return;
|
|
503
|
+
let d; try { d = readDelta(txState.file, txState.off); } catch (e) { return; }
|
|
504
|
+
if (!d.lines.length) return; txState.off = d.off;
|
|
505
|
+
const turns = linesToTurns(d.lines); if (!turns.length) return;
|
|
506
|
+
try { ws.send(JSON.stringify({ type: 'txdelta', window: txState.win, turns })); console.log(` 💬+ 라이브 델타 ${turns.length}턴`); } catch (e) {}
|
|
507
|
+
}
|
|
473
508
|
|
|
474
509
|
// ── WebSocket 상시 연결 (폴링 제거 — DO가 예약/명령을 push) ──
|
|
475
510
|
const wsUrl = api.replace(/^http/, 'ws') + '/ws?token=' + encodeURIComponent(token);
|
|
476
511
|
let ws = null, hbIv = null, scrIv = null;
|
|
477
512
|
function connectWS() {
|
|
478
513
|
try { ws = new WebSocket(wsUrl); } catch (e) { console.error(' WebSocket 생성 실패:', e.message); setTimeout(connectWS, 5000); return; }
|
|
479
|
-
ws.addEventListener('open', () => { console.log(' 🔌 클라우드 연결됨 (WebSocket).'); lastScr = ''; sendHb(); sendScreen(); clearInterval(hbIv); clearInterval(scrIv); hbIv = setInterval(sendHb, Math.max(every, 15000)); scrIv = setInterval(sendScreen, 2500); });
|
|
514
|
+
ws.addEventListener('open', () => { console.log(' 🔌 클라우드 연결됨 (WebSocket).'); lastScr = ''; sendHb(); sendScreen(); clearInterval(hbIv); clearInterval(scrIv); hbIv = setInterval(sendHb, Math.max(every, 15000)); scrIv = setInterval(() => { sendScreen(); sendTxDelta(); }, 2500); });
|
|
480
515
|
ws.addEventListener('message', (e) => { let m; try { m = JSON.parse(typeof e.data === 'string' ? e.data : e.data.toString()); } catch { return; } if (m.type === 'job') onJob(m); else if (m.type === 'ctl') runControl(m.cmd); });
|
|
481
516
|
ws.addEventListener('close', () => { clearInterval(hbIv); clearInterval(scrIv); console.log(' 🔌 연결 끊김 — 5초 후 재연결'); setTimeout(connectWS, 5000); });
|
|
482
517
|
ws.addEventListener('error', () => { try { ws.close(); } catch (e) {} });
|