@cliphijack/santaclaude 1.0.11 → 1.0.13

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/santaclaude.js +40 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -115,35 +115,48 @@ function readTail(file, maxBytes) {
115
115
  return s.split('\n');
116
116
  } catch (e) { return []; }
117
117
  }
118
- // JSONL user/assistant (툴콜=칩, 첨부이미지=data URL). 파일 꼬리만 읽고 최근 maxTurns개.
119
- function parseTurns(file, maxTurns) {
120
- const out = []; let imgBudget = 8 * 1024 * 1024; // 전체 이미지 base64 합계 상한(모바일 보호)
121
- const lines = readTail(file, 2 * 1024 * 1024); // 최근 ~2MB만 (통째로 X) 지적: "꼭 열어야되나"
122
- 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; }
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 턴 (툴콜=칩, 이미지). withImgs=false면 이미지 데이터 빼고 개수만(imgn) → seed 초경량·즉시
129
+ function linesToTurns(lines, withImgs) {
130
+ const out = []; let imgBudget = 6 * 1024 * 1024; // 이미지 base64 합계 상한(모바일 보호)
131
+ function grabImgs(c) { let urls = [], n = 0; if (!Array.isArray(c)) return { urls, n }; for (const b of c) { if (b && b.type === 'image' && b.source) { n++; if (withImgs) { const s = b.source; if (s.type === 'base64' && s.data && imgBudget > s.data.length) { imgBudget -= s.data.length; urls.push('data:' + (s.media_type || 'image/png') + ';base64,' + s.data); } else if (s.type === 'url' && s.url) urls.push(String(s.url)); } } } return { urls, n }; }
123
132
  for (const line of lines) {
124
133
  if (!line) continue; let o; try { o = JSON.parse(line); } catch (e) { continue; }
125
134
  if (o.type !== 'user' && o.type !== 'assistant') continue;
126
135
  const m = o.message; if (!m) continue; const c = m.content;
127
136
  if (o.type === 'user') {
128
- let text = ''; const imgs = grabImgs(c);
137
+ let text = ''; const gi = grabImgs(c);
129
138
  if (typeof c === 'string') text = c;
130
- 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만) 스킵
139
+ else if (Array.isArray(c)) { const t = c.filter((b) => b && b.type === 'text').map((b) => b.text); if (!t.length && !gi.n) continue; text = t.join('\n'); } // 텍스트도 이미지도 없으면(tool_result만) 스킵
131
140
  text = String(text || '').trim();
132
141
  if (text && /^<(command-name|local-command|command-message|command-args)/.test(text)) continue; // 슬래시명령 메타 스킵
133
- if (!text && !imgs.length) continue;
134
- out.push({ role: 'user', text: text.slice(0, 8000), imgs });
142
+ if (!text && !gi.n) continue;
143
+ out.push({ role: 'user', text: text.slice(0, 8000), imgs: gi.urls, imgn: gi.n });
135
144
  } else {
136
- let text = '', tools = []; const imgs = grabImgs(c);
145
+ let text = '', tools = []; const gi = grabImgs(c);
137
146
  if (typeof c === 'string') text = c;
138
147
  else if (Array.isArray(c)) for (const b of c) {
139
148
  if (b && b.type === 'text' && b.text) text += (text ? '\n' : '') + b.text;
140
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) : '')); }
141
150
  }
142
- text = String(text || '').trim(); if (!text && !tools.length && !imgs.length) continue;
143
- out.push({ role: 'assistant', text: text.slice(0, 8000), tools, imgs });
151
+ text = String(text || '').trim(); if (!text && !tools.length && !gi.n) continue;
152
+ out.push({ role: 'assistant', text: text.slice(0, 8000), tools, imgs: gi.urls, imgn: gi.n });
144
153
  }
145
154
  }
146
- return out.slice(-Math.max(10, Math.min(200, maxTurns || 60)));
155
+ return out;
156
+ }
157
+ // 파일 꼬리(최근 2MB)만 읽어 최근 maxTurns개 (seed=텍스트만, 이미지 데이터 X → 초경량 즉시표시)
158
+ function parseTurns(file, maxTurns) {
159
+ return linesToTurns(readTail(file, 2 * 1024 * 1024), false).slice(-Math.max(10, Math.min(200, maxTurns || 60)));
147
160
  }
148
161
 
149
162
  async function post(api, p, body) {
@@ -262,6 +275,7 @@ async function run(conf) {
262
275
  const session = String(pane).split(':')[0];
263
276
  const claudeCmd = conf.claudeCmd || 'claude --dangerously-skip-permissions';
264
277
  let warned = false; // 중복방지는 서버가 책임 (claim 시 next_fire 원자적 전진) — 클라 영구셋은 반복예약을 영구차단하므로 안 둠
278
+ let txState = { until: 0, off: 0, file: null, win: '' }; // 💬 루돌프톡 라이브: 무장시각·읽은 오프셋·세션파일·창
265
279
 
266
280
  cleanOldImages();
267
281
  console.log(`🛷 SantaClaude 커넥터 가동 — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
@@ -373,14 +387,16 @@ async function run(conf) {
373
387
  if (rr > 0) args.push('-y', String(Math.max(10, Math.min(200, rr))));
374
388
  if (args.length > 3) { try { execFileSync('tmux', args); console.log(` 📐 리사이즈 ${w} → 폭${c || '그대로'}${rr ? '×높이' + rr : ''}`); } catch (e) {} }
375
389
  } else if (cmd.action === 'transcript') {
376
- // 💬 루돌프의 claude 대화 전체내역(JSONL) 읽어 웹에 전달우리 서버엔쌓음(TTL 릴레이)
390
+ if (cmd.live) { txState.until = Date.now() + 45000; return; } // keepalive 라이브 모드만 연장(seed 함, 초경량)
391
+ // 💬 seed: 전체내역(JSONL 꼬리) 읽어 웹에 전달 + 라이브 모드 무장(이후 델타는 화면틱에 묻어감)
377
392
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
378
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) {}
379
394
  const file = findTranscript(cwd);
380
395
  let turns = [], found = !!file;
381
396
  if (file) turns = parseTurns(file, parseInt(cmd.n) || 60);
382
397
  try { await post(api, '/api/transcript', { token, window: cmd.name || '', turns, found }); } catch (e) {}
383
- console.log(` 💬 대화내역 ${turns.length} 웹${found ? '' : ' (트랜스크립트 찾음)'}`);
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)' : ' (트랜스크립트 못 찾음)'}`);
384
400
  }
385
401
  } catch (e) { console.warn(` ⚠️ 제어 실패(${cmd.action} ${cmd.name}): ${e.message}`); }
386
402
  }
@@ -481,13 +497,21 @@ async function run(conf) {
481
497
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
482
498
  let lastScr = '';
483
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, true); if (!turns.length) return; // 델타는 새 턴 1~2개라 이미지 포함 OK(가벼움)
506
+ try { ws.send(JSON.stringify({ type: 'txdelta', window: txState.win, turns })); console.log(` 💬+ 라이브 델타 ${turns.length}턴`); } catch (e) {}
507
+ }
484
508
 
485
509
  // ── WebSocket 상시 연결 (폴링 제거 — DO가 예약/명령을 push) ──
486
510
  const wsUrl = api.replace(/^http/, 'ws') + '/ws?token=' + encodeURIComponent(token);
487
511
  let ws = null, hbIv = null, scrIv = null;
488
512
  function connectWS() {
489
513
  try { ws = new WebSocket(wsUrl); } catch (e) { console.error(' WebSocket 생성 실패:', e.message); setTimeout(connectWS, 5000); return; }
490
- 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); });
491
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); });
492
516
  ws.addEventListener('close', () => { clearInterval(hbIv); clearInterval(scrIv); console.log(' 🔌 연결 끊김 — 5초 후 재연결'); setTimeout(connectWS, 5000); });
493
517
  ws.addEventListener('error', () => { try { ws.close(); } catch (e) {} });