@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/santaclaude.js +51 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
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 user/assistant (툴콜은 칩으로 요약). 최근 maxTurns개.
110
- function parseTurns(file, maxTurns) {
111
- const out = [];
112
- let lines; try { lines = fs.readFileSync(file, 'utf8').split('\n'); } catch (e) { return out; }
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'); } // tool_result만이면 사용자 발화 아님 → 스킵
121
- text = String(text || '').trim(); if (!text) continue;
122
- if (/^<(command-name|local-command|command-message|command-args)/.test(text)) continue; // 슬래시명령 메타 스킵
123
- out.push({ role: 'user', text: text.slice(0, 8000) });
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.slice(-Math.max(10, Math.min(200, maxTurns || 60)));
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
- // 💬 루돌프의 claude 대화 전체내역(JSONL) 읽어 웹에 전달우리 서버엔쌓음(TTL 릴레이)
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
- 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)' : ' (트랜스크립트 못 찾음)'}`);
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) {} });